ARM-汇编的艺术-全-

ARM 汇编的艺术(全)

原文:zh.annas-archive.org/md5/2a18876965e33fa7d6df211719acb3f9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读 The Art of ARM Assembly。本书将教你如何编写 64 位 ARM CPU 程序,如现代 Apple macOS 机器中使用的 ARM CPU,基于 ARM 的 Linux 系统(包括带有 64 位 Raspberry Pi OS 版本的 Raspberry Pi,以前称为 Raspbian,我简称为 Pi OS),甚至是 iPhone、iPad 以及部分 Android 设备等移动设备。随着 ARM 架构的 Apple macOS 系统的到来,学习和理解 64 位 ARM 汇编语言的需求急剧增加,这也促使我写了这本书。然而,我已尽可能使本书中的源代码和其他信息具有便携性,以便它适用于所有 64 位 ARM 机器。

本书是 The Art of 64-Bit Assembly 的姊妹篇,而 The Art of 64-Bit Assembly 本身是 The Art of Assembly Language ProgrammingAoA)的重写版本。AoA 是我在 1989 年开始的一个项目,旨在为加利福尼亚州立理工大学(Pomona)和加利福尼亚大学河滨分校的学生教授 80x86(x86)汇编语言编程。25 多年来,AoA 一直是学习 x86 汇编语言编程的指南。在这段时间里,其他处理器有来有去,但 x86 依然是个人计算机和高端工作站的主流,而 x86 汇编语言也始终是学习的事实标准。然而,随着基于 ARM 的 Apple M1 系统(以及后来的 Apple 机器)的出现,ARM 架构的个人计算机逐渐成为主流,因此学习 ARM 汇编语言编程的需求大幅增加。

本书的编写以 The Art of 64-Bit Assembly 为模型,涵盖了类似的内容。任何读过我早期书籍的人都会觉得这本书在高层次上非常熟悉。当然,ARM 指令和汇编器——无论是 GNU 汇编器(Gas)还是 Apple 的 Clang 汇编器(与 Gas 大部分兼容)——与 x86 指令和微软宏汇编器(MASM)有很大不同。因此,底层的呈现和编程技巧也有所不同。

0.1 ARM CPU 的简史

ARM CPU 拥有悠久而丰富的历史。它最初由 Acorn Computers Ltd. 于 1983 年底开发,作为其 BBC Micro 系统中使用的老旧 8 位 6502 CPU 的替代品。ARM 最初代表的是 Acorn RISC Machine,但后来改为 Advanced RISC MachineRISC 代表 精简指令集计算机)。最初的设计在很大程度上是加州大学伯克利分校早期 RISC 设计与 6502 CPU 设计的结合。因此,许多人认为 ARM 起初并不是一个纯粹的 RISC 设计。我们可以将 ARM 看作是 6502 的精神继承者,继承了许多 6502 的特点。

在许多方面,ARM CPU 的设计借鉴了 6502 CPU 对简化指令集计算机的理解。在最初的 RISC 设计中,每条指令的设计都力求做最少的工作,以便减少对硬件的支持需求并提高运行速度。纯 RISC 架构通常不使用条件码位(因为在执行指令后设置条件码需要 CPU 做额外的工作),并且使用固定大小的机器指令编码(通常为 32 位)。而 6502 则试图尽可能减少机器指令的数量

此外,原始的 ARM 支持 16 位和 32 位指令编码。虽然纯 RISC CPU 尝试最大化通用寄存器的数量(通常是 32 个),但原始的 ARM 设计仅支持 16 个寄存器。此外,ARM 使用一个通用寄存器作为程序计数器,这使得可以进行各种编程技巧,但也给纯 RISC 设计带来了问题(如处理异常)。最后,ARM 部分支持硬件堆栈,这是纯 RISC 机器上看不到的功能。尽管如此,不管是“纯粹”还是“非纯粹”,ARM 设计比那个时代的所有其他 RISC CPU 更加长久。

多年来,ARM CPU 变种主要应用于移动和嵌入式领域,绝大多数 ARM CPU 最终应用于手机和平板电脑。然而,有一个显著的应用是在树莓派计算机系统中(截至撰写时,已经销售超过 6100 万台)。除了树莓派,数百万台基于 ARM 的兼容 Arduino 的单板计算机(如 Teensy 系列)也已售出。截至撰写时,树莓派基金会发布了树莓派 Pico,一款基于 ARM 的微控制器板,售价 4 美元(美国),到 2024 年 1 月已售出超过 400 万台此类设备。

0.2 为什么要学习 ARM 汇编?

RISC CPU 的设计初衷是使用高级语言(特别是 C/C++)进行编程。很少有著名的程序是用 RISC 汇编语言编写的(尽管原始的 ARM Basic 是一个很好的反例)。在大学和学院中教授汇编语言的主要原因是为了教授机器组织(即对机器架构的介绍)。此外,一些应用程序(或者至少是某些应用程序的部分)可以从汇编语言实现中获益。速度和空间是使用汇编语言的两个主要原因,尽管某些算法确实更容易用汇编语言编写(特别是位处理操作)。

最后,学习汇编语言有助于你编写更高质量的高级语言代码。毕竟,像 C/C++这样的语言的编译器将高级源代码翻译成汇编语言。理解底层的机器语言将帮助你编写更好的高级语言(HLL)代码,因为你可以避免低效的 HLL 结构。这种理解在调试或优化 HLL 代码时也非常有用。有时,你必须查看编译器生成的代码,才能理解某个 bug 或效率低下的问题。

那么,为什么特别要写一本关于 ARM 汇编语言的书呢?直到苹果硅 M1 CPU 问世之前,唯一使用 ARM CPU 的常见个人电脑就是树莓派。虽然树莓派很受欢迎,但它通常并未在学校中用于教授计算机组织和汇编语言编程。一些业余爱好者可能会自学 ARM 汇编语言,但大多数树莓派程序员使用的是 Scratch 或 Python,而硬核玩家则使用 C/C++编程。尽管 iPhone、iPad 和安卓手机、平板等移动设备也很受欢迎,但开发者很少会考虑从 Objective-C、Swift 或 Java 切换到汇编语言来为这些设备的应用程序编程。

然而,一旦苹果发布了基于 M1 的 Mac mini、MacBook 和 iMac,情况发生了变化。由于现在 ARM 汇编语言可以在“普通”机器上教授,ARM 低级编程的兴趣激增。自从 A 系列(iPad 和 iPhone)和 M 系列(iPad 和 Mac)系统推出以来,苹果销售的数量超过了树莓派。可以预见的是,到你阅读这本书时,苹果可能已经售出了约十亿台基于 ARM 的个人电脑和移动设备。

鉴于这些发展,越来越多的人将对在 ARM CPU 上进行汇编语言编程感兴趣。如果你希望能够在这批新设备上编写高性能、高效且体积小的代码,那么学习 ARM 汇编语言是开始的好地方。

0.3 为什么要学习 64 位 ARM?

尽管最初的 ARM 是 32 位 CPU,但授权 ARM 设计的公司 Arm Holdings 在 2011 年推出了 64 位版本。苹果在几年后推出了其 32 位的 iPhone 5。从那时起,大多数移动设备和个人计算机(包括树莓派 3、4 和 400)都使用了 64 位 CPU,而嵌入式设备则大多坚持使用 32 位 CPU 版本。为 32 位 CPU 编写的代码通常比为 64 位 CPU 编写的代码更节省内存;除非应用程序需要超过 4GB 的内存,否则使用 32 位指令集通常更好。

尽管如此,对于高性能计算,64 位无疑是未来。为什么会这样呢?难道 64 位 ARM 处理器不能运行旧的 32 位代码吗?答案是有条件的“是”。例如,Raspberry Pi 提供了一个 32 位操作系统,即使在 64 位 CPU(如 Pi 3、4 或 400)上运行,它也只运行 32 位代码。然而,64 位 ARM 处理器(ARMv8 或 AARCH64,简称 ARM64)有两种工作模式:32 位模式和 64 位模式。当处于 32 位模式时,它们执行 32 位指令集;而在 64 位模式下,它们执行 64 位指令集。尽管这两种指令集有一些相似之处,但它们并不相同。因此,当操作处于某个模式时,不能执行来自另一个模式的指令。

由于这两种指令集不兼容,本书重点讲解 64 位 ARM 汇编语言。因为你无法在 Apple M1(及之后的版本)上使用 32 位 ARM 汇编语言进行编程,单独教授 32 位汇编语言是不可行的。为什么不同时教授两者呢?虽然学习 32 位汇编语言对想要为 32 位 Pi 操作系统及其他嵌入式单板微控制器编写代码的读者有帮助,但本书的目标是教授基础知识。教授两种不同的指令集会让教育体验变得复杂;与其两者都做得不好,不如专注于做好一件事(64 位汇编)。同时教授 32 位和 64 位汇编语言几乎就像是试图在同一本书中教授 x86-64 和 ARM;一次性学习太多内容是不现实的。此外,32 位操作模式可能会随着时间的推移完全消失。当我写这篇文章时,ARM 已经推出了只支持 64 位代码的变种;我预计未来所有桌面级处理器都会朝这个方向发展。

注意

虽然专注于桌面级和移动设备(如 iPhone)上的 64 位 ARM 汇编语言是合理的,但一些人可能希望学习 32 位 ARM 汇编语言,以便与嵌入式设备一起使用。基于 Arduino 的单板计算机(SBC)、Raspberry Pi Pico SBC 以及许多其他基于 ARM 的嵌入式系统使用 32 位 ARM 变种。此外,如果你在使用 32 位版本的 Pi OS 操作 Raspberry Pi,那么你需要使用 32 位 ARM 汇编语言。因此,《ARM 汇编语言艺术(第二卷)》将涵盖这些系统上的 32 位 ARM 汇编语言。

0.4 期望与前提条件

本书假设你已经能够熟练使用高级语言(HLL)进行编程,例如 C/C++(首选)、Python、Swift、Java、Pascal、Ruby、BASIC 或其他面向对象或命令式(过程式)编程语言。尽管许多程序员成功地将汇编语言作为他们的第一种编程语言学习,但我建议你先学习如何编程,然后再学习汇编语言编程。本书使用了若干个高级语言(HLL)的例子(通常是 C/C++或 Pascal)。这些例子通常很简单,所以如果你了解其他 HLL 语言,应该能够理解它们。

本书还假设你已经熟悉在程序开发过程中进行编辑/编译/测试/调试的循环。你应该熟悉源代码编辑器和使用标准软件开发工具,因为我不会解释如何编辑源文件。

市面上有各种各样的 64 位 ARM 系统,我的目标是使本书适用于尽可能多的系统。为此,本书中的每个示例程序都已在以下系统上进行测试:

  • 基于 Apple M1 的 Mac 系统,如 Mac mini M1 和 Mac mini M2。书中的示例代码已经在 mini M1 上进行测试,但应适用于任何基于 ARM 的 MacBook 或 iMac,以及未来的 Mx 系统。

  • Raspberry Pi 3、4、400 和 5 系统(以及未来支持 64 位的 Pi 系统),运行 64 位版本的 Pi OS。

  • PINE64 系统,包括 Pinebook、Pinebook Pro 和 ROCKPro 64。

  • 几乎任何 64 位 ARM 架构的 Linux 系统。

  • NVIDIA Jetson Nano 系统。

理论上,应该可以将本书中的信息应用到基于 ARM 的 Windows 机器(如 Surface Laptop Copilot+)上。不幸的是,微软的软件开发工具,特别是其汇编器,是基于 Arm(公司)定义的原始 ARM 汇编语法,而不是 Gas。虽然微软的 armasm64 在许多方面是更好的工具(因为它使用标准的 ARM 汇编语言语法),但其他人都使用 Gas 语法。两种汇编器之间的机器指令大致相同,但其他语句(被称为 汇编指令伪操作码)完全不同。因此,用 Gas 编写的示例程序在 armasm64 下无法汇编,反之亦然。由于在示例程序中同时展示两种语法形式与同时教授 32 位和 64 位编程一样令人困惑,因此我在示例中坚持使用 Gas 语法。

0.5 源代码

本书包含了大量的 ARM 汇编语言(以及一些 C/C++)源代码,通常以三种形式之一呈现:代码片段、单个汇编语言过程或函数(模块)或完整程序。

代码片段是程序的片段;它们不是独立的,无法使用 ARM 汇编器(或在 C/C++ 源代码的情况下使用 C++ 编译器)进行编译。它们存在的目的是阐明某个观点或提供某种特定编程技巧的简短示例。以下是一个典型的示例:

 .data
i64  .quad   0
      .
      .
      .
     ldr     x1, i64

垂直省略号表示可以替代其位置的任意代码。

模块是可以编译但无法独立运行的小块代码。模块通常包含一个将被另一个程序调用的函数。以下是一个典型的示例:

someFunc:
        add x0, x1, x2
        sub x0, x0, x3
        ret

本书中完整的程序被称为 listings,我通过列表编号或文件名引用它们。典型的文件名通常采用 ListingC-N.S 的形式,其中 C 是章节编号,N 是该章节中的列表编号。例如,以下 Listing1-1.S 是出现在 第一章中的第一个列表:

// Listing1-1.S
//
// Comments consist of all text from a //
// sequence to the end of the line.
// The .text directive tells MASM that the
// statements following this directive go in
// the section of memory reserved for machine
// instructions (code).

        .text

// Here is the main function.
// (This example assumes that the
// assembly language program is a
// stand-alone program with its own
// main function.)
//
// Under macOS, the main program
// must have the name _main
// beginning with an underscore.
// Linux systems generally don't
// require the underscore.
//
// The .global _main statement
// makes the _main procedure's name
// visible outside this source file
// (needed by the linker to produce
// an executable).

        .global _main

// The .align 2 statement tells the
// assembler to align the following code
// on a 4-byte boundary (required by the
// ARM CPU). The 2 operand specifies
// 2 raised to this power (2), which
// is 4.

        .align 2

// Here's the actual main program. It
// consists of a single ret (return)
// instruction that simply returns
// control to the operating system.

_main:
        ret

尽管大多数列表的格式为 ListingC-N.S,但有些(尤其是来自外部来源的)仅由描述性文件名组成,例如本书大多数示例程序使用的 aoaa.inc 头文件。

所有的列表都可以在电子版中找到,网址是 <wbr>artofarm<wbr>.randallhyde<wbr>.com,可以单独下载,也可以作为一个包含本书所有列表的 ZIP 文件下载。该页面还包含本书的支持信息,包括勘误表和供讲师使用的 PowerPoint 演示文稿。

本书中的大多数程序都是通过命令行运行的。这些示例通常使用 bash shell 解释器。因此,每个构建命令和示例输出通常会在您键入的命令前加上 $ 或 % 前缀。在 macOS 下,默认的 shell(命令行)程序是 zsh,它使用百分号(%)而不是美元符号($)作为提示符。如果您完全不熟悉 Linux 或 macOS 的命令行,请参见 附录 D,以便快速了解命令行解释器。

除非另有说明,本书中出现的所有源代码均受 Creative Commons 4.0 许可协议保护。根据 Creative Commons 许可协议,您可以自由地在自己的项目中使用这些代码。更多详情请参见 <wbr>creativecommons<wbr>.org<wbr>/licenses<wbr>/by<wbr>/4<wbr>.0<wbr>/

0.6 排版和迂腐

计算机书籍有一个习惯,就是滥用英语语言,本书也不例外。每当源代码片段出现在英语句子中间时,编程语言的语法规则与英语语法规则之间往往会产生冲突。本节将描述我在区分英语与编程语言语法规则时所做的选择,以及一些其他约定。

首先,本书使用等宽字体表示程序源文件中的任何文本。这包括变量、过程函数、程序输出以及用户输入。因此,当您看到类似 get 这样的内容时,您知道本书是在描述程序中的标识符,而不是命令您去获取某些东西。

一些逻辑操作有着与英语中常见的含义相同的名称:AND、OR 和 NOT。使用这些术语作为逻辑函数时,本书采用全大写字母来帮助区分可能会引起混淆的英语表述。使用这些术语作为英语单词时,本书使用标准的排版字体。第四个逻辑运算符,异或(XOR),通常不会出现在英语句子中,但本书仍然将其大写。

通常情况下,我总是尽量在第一次使用缩写或简称时进行定义。如果我有一段时间没有使用某个术语,我会在重新使用时再进行一次定义。附录 B 中的术语表也包括了书中大多数出现的缩写。

0.7 组织结构

本书分为 4 个部分,共 16 章和 6 个附录。

第一部分,机器组织,介绍了 ARM 处理器的数据类型和机器架构:

第一章:你好,汇编语言的世界    教授你一些基本的指令,让你可以试验软件开发工具并编写简单的小程序。

第二章:数据表示与运算    讨论了整数、字符和布尔值等简单数据类型的内部表示。它还讨论了对这些数据类型可以进行的各种算术和逻辑运算。本章还介绍了一些基本的 ARM 汇编语言操作数格式。

第三章:内存访问与组织    讨论了 ARM 如何组织主内存。它解释了内存布局以及如何声明和访问内存变量。它还介绍了 ARM 访问内存和栈(用于存储临时值)的方法。

第四章:常量、变量和数据类型    描述了如何在汇编语言中声明命名常量,如何声明和使用指针,以及如何使用复合数据结构,如字符串、数组、结构体(记录)和联合。

第二部分,基础汇编语言,提供了编写汇编语言程序所需的基本工具和指令。

第五章:过程    介绍了编写你自己的汇编语言函数(过程)所需的指令和语法。本章描述了如何将参数传递给函数并返回函数结果。它还描述了如何声明(并使用)你在栈上分配的局部或自动变量。

第六章:算术    解释了 ARM 汇编语言中的基本整数算术和逻辑运算。它还描述了如何将算术表达式从高级语言(HLL)转换为 ARM 汇编语言。最后,本章介绍了使用硬件支持的浮点指令进行浮点算术运算。

第七章:低级控制结构    描述了如何在 ARM 汇编语言中实现类似高级语言(HLL)的控制结构,如 if、elseif、else、while、do...while(repeat...until)、for 和 switch。本章还涉及了汇编语言中优化循环和其他代码的方法。

第三部分,高级汇编语言,涵盖了更高级的汇编语言操作。

第八章:高级算术    探讨了扩展精度算术、混合模式算术以及其他高级算术运算。

第九章:数值转换    提供了一组非常有用的库函数,你可以使用它们将数值转换为字符串格式,或将字符串转换为数值格式。

第十章:表格查找    描述如何使用基于内存的查找表(数组)来加速某些计算。

第十一章:Neon 和 SIMD 编程    讨论了 ARM 高级 SIMD 指令集,通过同时操作多个数据项来加速某些应用程序。

第十二章:位操作    描述了在 ARM 汇编语言中执行位级数据操作的各种操作和函数。

第十三章:宏和 Gas 编译时语言    介绍了 Gas 宏功能。宏是强大的构造,允许你设计自己的汇编语言语句,这些语句会扩展为大量单独的 ARM 汇编语言指令。

第十四章:字符串操作    解释了在 ARM 汇编语言中使用和创建各种字符字符串库函数。

第十五章:管理复杂项目    描述了如何创建汇编语言代码库,并通过使用 makefile 构建这些库(同时讨论 make 语言)。

第十六章:独立汇编语言程序    展示了如何编写不使用 C/C++标准库进行 I/O 和其他操作的汇编语言应用程序。本章包括适用于 Linux(Pi OS)和 macOS 的系统调用示例。

第四部分,参考资料,提供了参考信息,包括列出完整 ASCII 字符集的表格、词汇表、安装和使用 Gas 的系统说明、bash shell 解释器简介、你可以在汇编语言程序中调用的有用 C/C++函数,以及每章末尾问题的答案。

第一部分 机器组织

第一章:1 汇编语言的世界,你好

本章是一个“快速入门”章节,旨在让您尽可能快速地开始编写基本的汇编语言程序,为您学习后续章节中的新汇编语言特性提供必要的技能。您将学习 64 位 ARM 架构的基础知识以及 GNU 汇编器(Gas)程序的基本语法,Gas 是一个汇编语言编译器。

您还将学习为变量分配内存、通过使用机器指令控制 CPU,并将 Gas 程序与 C/C++代码链接,以便调用 C 标准库(C stdlib)中的例程。Linux 和 macOS 下运行的 Gas 汇编器是编写实际 ARM 汇编语言程序最常用的汇编器。供应商(特别是苹果公司)生产了 Gas 的变体,具有略微不同的语法;例如,在 macOS 下,Gas 被称为ClangMach-O汇编器。为了使本书中的源代码能够在 macOS 和 Linux 之间可移植,本章还介绍了一个头文件aoaa.inc,它消除了 Gas 和 Clang 汇编器之间的差异。

1.1 您需要的工具

要使用 Gas 学习汇编语言编程,您需要为您的平台准备一个汇编器版本,以及一个文本编辑器,用于创建和修改 Gas 源文件,还有一个链接器、各种库文件和一个 C++编译器。您将在本节中学习如何设置 Gas 汇编器和文本编辑器,其他工具将在本章后续部分介绍。

1.1.1 设置 Gas

GNU 编译器集合(GCC)将 Gas 源代码作为其输出(然后 Gas 将其转换为目标代码)。因此,如果您的系统上已经安装了编译器套件,那么您也就拥有了 Gas。苹果 macOS 使用基于 LLVM 编译器套件的编译器,而不是 GCC,因此如果您使用 macOS,您需要安装其 Xcode 集成开发环境(IDE)以访问汇编器(见附录 C)。如果没有 GCC 编译器,请根据您的操作系统(OS)文档中的说明安装它。

注意

GNU 汇编器和 Clang 汇编器的可执行文件名实际上是as(汇编器)。本书中的示例很少直接调用汇编器,因此您不会经常使用as程序。因此,本书使用Gas而不是as(或 Clang 汇编器)来指代汇编器。

1.1.2 设置文本编辑器

要编写 ARM 汇编语言程序,您需要某种程序员文本编辑器来创建汇编语言源文件。编辑器的选择取决于个人喜好以及操作系统或开发套件中可用的编辑器。

汇编语言源文件的标准后缀是 .s,因为在编译时,GCC 会在将 C/C++ 文件转换为汇编语言时发出这个后缀。对于手写的汇编语言源文件,.S 后缀是更好的选择,因为它告诉汇编器在汇编之前通过 C 预处理器(CPP)处理源文件。由于这样可以使用 CPP 宏(#define 语句)、条件编译和其他功能,因此本书中的所有示例文件都使用 .S

GCC 总是生成汇编语言输出文件,然后由 Gas 处理。GCC 会自动调用汇编器,并在汇编完成后删除汇编源文件。

1.1.3 理解 C/C++ 示例

现代软件工程师只有在 C/C++、C#、Java、Swift 或 Python 代码运行得过慢,需要提升某些模块或函数的性能时,才会进入汇编语言。书中的示例使用 C/C++,因为在实际应用中,你通常会将汇编语言与 C/C++ 或其他高级语言(HLL)代码进行接口对接。

C/C++ 标准库是使用该语言的另一个重要原因。为了让 C 标准库能够立即在 Gas 程序中使用,我提供了一个示例,其中包含一个简短的 C++ 主函数,该函数调用一个用汇编语言编写的外部函数(使用 Gas)。将 C++ 主程序与 Gas 源文件一起编译,生成一个单独的可执行文件,你可以运行并进行测试。

本书会手把手地教你运行示例高级语言程序所需的 C++,即使你不精通该语言,也能跟得上。不过,如果你对 C/C++ 有一点基础,会更加容易理解。本书至少假设你有使用 Pascal(或 Delphi)、Java、Swift、Rust、BASIC、Python 或其他任何命令式或面向对象的编程语言的经验。

1.2 汇编语言程序的结构

一个典型的(独立的)Gas 程序呈现出示例 1-1 所示的形式。

// Listing1-1.S
//
// Comments consist of all text from a //
// sequence to the end of the line.
// The .text directive tells Gas that the
// statements following this directive go in the
// section of memory reserved for machine
// instructions (code).

      ❶ .text

// Here is the main function. (This example assumes
// that the assembly language program is a
// stand-alone program with its own main function.)
//
// Under macOS, the main program must have the name
// _main beginning with an underscore. Linux
// systems generally don't require the underscore.
//
// The .global _main statement makes the _main
// procedure's name visible outside this source file
// (needed by the linker to produce an executable).

        .global _main, main

// The .align 2 statement tells the assembler to
// align the following code on a 4-byte boundary
// (required by the ARM CPU). The 2 operand
// specifies 2 raised to this power (2), which is 4.

 ❷ .align 2

// Here's the actual main program. It consists of a
// single ret (return) instruction that simply
// returns control to the operating system.

_main:
main:
        ret

汇编语言程序被分为。有些段包含数据,有些包含常量,有些包含机器指令(可执行语句),依此类推。示例 1-1 包含一个单独的代码段,在 macOS 和 Linux 中称为text.text 语句 ❶ 告诉汇编器,接下来的语句属于代码段。

在汇编语言源文件中,符号通常是局部的或私有的,仅限于某个源文件。在创建可执行源文件时,你必须将一个或多个符号传递给系统链接器——至少是主程序的名称。你可以通过使用 .global 语句来实现,并将全局名称作为操作数指定:在 macOS 中为 _main,在 Linux 中为 main。如果省略这个语句,在尝试编译源文件时会出现错误。

ARM 指令集要求所有机器指令在内存中以 32 位(4 字节)边界开始。因此,在 .text 区段中的第一条机器指令之前,您需要告诉汇编器将地址对齐到 4 字节边界。 .align 语句 ❷ 将其操作数指定的指数作为 2 的幂来对齐下一条指令。由于 2² 等于 4,因此此语句将下一条指令对齐到 4 字节边界。

在 ARM 汇编中,一个过程或函数仅由该函数的名称(此例中为 _main 或 main)后跟冒号组成。接下来是机器指令。此示例中的主程序由一条机器指令组成:ret(返回)。该指令立即将控制权返回给调用主程序的地方——即操作系统。

Gas 中的标识符类似于大多数高级语言中的标识符。Gas 标识符可以以美元符号($)、下划线(_)或字母字符开头,并且可以后跟零个或多个字母数字、美元符号或下划线字符。符号区分大小写。

虽然清单 1-1 中的程序实际上什么也不做,但您可以通过它学习如何使用汇编器、链接器以及编写 ARM 汇编语言程序所需的其他工具,正如我们将在下一节中所做的那样。

1.3 运行您的第一个汇编语言程序

一旦您有了汇编源文件,就可以编译并运行该程序。理论上,您可以先运行汇编器(as),然后再运行链接器(ld,提供操作系统所需的适当库文件)。以下是在 macOS 上的操作示例(每行开头的 $ 是操作系统的 shell 提示符):

$ as -arch arm64 Listing1-1.S -o Listing1-1.o 
$ ld -o Listing1-1 Listing1-1.o -lSystem \
 -syslibroot `xcrun -sdk macosx --show-sdk-path` \
 -e _main -arch arm64 
$ ./Listing1-1 

然而,命令行在不同的操作系统中有所不同,而且以这种方式生成可执行文件需要大量的输入。更简单的编译程序并生成可执行文件的方法是使用 GCC 编译器(g++),通过运行以下命令:

$ g++ -o Listing1-1 Listing1-1.S 

该命令行甚至可以在 macOS 上运行,macOS 使用 Clang 编译器而不是 GCC;macOS 为 Clang 创建了一个名为 g++ 的别名。在 macOS 上,您也可以使用 clang -o Listing1-1 Listing1-1.S 命令行。不过,本书将坚持使用 g++ 命令行,因为它在 macOS 和 Linux 上都有效。

g++ 命令足够智能,能够识别这是一个汇编语言源文件,并运行 Gas 来生成目标文件。GCC 接着会运行链接器(ld),并提供操作系统所需的所有默认库。

您可以按照以下方式从命令行运行生成的可执行文件:

$ ./Listing1-1 

该程序立即返回且没有任何输出,因为清单 1-1 只做这件事;它仅仅是用来演示如何编译和运行 ARM 汇编语言程序的。

除了减少所需输入的字符数外,使用 g++来汇编你的汇编语言源文件还提供了另一个好处:它是运行 CPP 的最简单方式,而本书中的许多示例文件都需要这个 CPP。你可以像下面这样单独调用 CPP 来处理汇编源文件,以查看 CPP 对你的汇编源文件所做的修改:

$ g++ -E Listing1-1.S 

你甚至可以使用以下命令将 CPP 的输出通过管道传递给 Gas:

$ g++ -E Listing1-1.S | as -o Listing1-1.o 

然而,在那时,你完全可以输入

$ g++ -o Listing1-1.o Listing1-1.S 

因为它更短且更易于输入。

1.4 运行你的第一个 Gas/C++混合程序

本书通常将包含一个或多个用汇编语言编写的函数的汇编语言模块与一个调用这些函数的 C/C++主程序相结合。由于编译和执行过程与独立的 Gas 程序略有不同,本节将演示如何创建、编译和运行一个混合汇编/C++程序。列表 1-2 提供了调用汇编语言模块的主要 C++程序。

// Listing1-2.S 
//
// A simple C++ program that calls 
// an assembly language function 
//
// Need to include stdio.h so this 
// program can call printf().

#include <stdio.h>

// extern "C" namespace prevents 
// "name mangling" by the C++
// compiler. 

extern "C"
{
    // Here's the external function, 
 // written in assembly language, 
    // that this program will call: 

     void asmMain(void); 
};

int main(void) 
{
    printf("Calling asmMain:\n"); 
    asmMain();
    printf("Returned from asmMain\n"); 
}

列表 1-3 是一个稍微修改过的独立 Gas 程序,其中包含 C++程序调用的 asmMain()函数。列表 1-3 和列表 1-1 之间的主要区别是函数名从 _main 更改为 _asmMain。如果我们继续使用 _main 这个名字,C++编译器和链接器会感到困惑,因为 _main 也是 C++主函数的名称。

// Listing1-3.S 
//
// A simple Gas module that contains 
// an empty function to be called by 
// the C++ code in Listing 1-2 

        .text 

// Here is the asmMain function: 

        .global _asmMain, asmMain 
        .align  2    // Guarantee 4-byte alignment. 
_asmMain: 
asmMain: 

// Empty function just returns to C++ code. 

        ret          // Returns to caller 

最后,为了编译和运行这些源文件,请运行以下命令:

$ g++ -o Listing1-2 Listing1-2.cpp Listing1-3.S 
$ ./Listing1-2 
Calling asmMain: 
Returned from asmMain 
$

诚然,这个汇编语言示例除了演示如何编译和运行一些汇编代码外,并没有做太多其他事情。要编写真正的汇编代码,你需要大量的支持代码。下一节将介绍提供部分支持的aoaa.inc头文件。

1.5 aoaa.inc 包含文件

本书中的示例代码旨在尽可能使 macOS 和 Linux 汇编器之间的代码具有可移植性,这是一项艰巨的任务,需要相当多的幕后高级技巧。许多这些技巧对于初学者 ARM 程序员来说有些过于高级,因此我将所有这些魔法代码包含在一个特殊的头文件aoaa.inc中,从此以后我会在大部分示例程序中使用它。

这个人类可读的包含文件几乎和典型的高级 C/C++头文件一样;它仅包含一些宏(例如 C/C++的#define 语句),帮助平滑处理 macOS 和 Linux 版本的汇编器之间的差异。当你读到本书的最后(特别是当你阅读第十三章时),头文件中的大部分内容将变得完全清晰。现在,我不会用高级宏和条件汇编的信息来分散你的注意力。

你可以在* <wbr>artofarm<wbr>.randallhyde<wbr>.com 找到aoaa.inc*及所有其他示例代码。如果你对这个文件的内容感兴趣,并且不想等到第十三章,可以将其加载到文本编辑器中查看。

要在汇编中包含此文件,请在你的汇编语言源文件中使用以下 CPP 语句:

#include "aoaa.inc"

就像在 C/C++ 中一样,这条语句将在汇编时自动将该文件的内容插入到当前源文件中(在 #include 语句处)。

Gas 有自己的 include 语句,使用方法如下:

.include "include_file_name"

然而,不要使用这个语句在源文件中包含 aoaa.inc。Gas 的 .include 指令在 CPP 运行之后执行,但 aoaa.inc 包含 CPP 宏、条件编译语句和其他必须由 CPP 处理的代码。如果你使用 .include 指令而不是 #include,CPP 将无法看到 aoaa.inc 文件的内容,而 Gas 在处理该文件时会产生错误。

在汇编过程中,aoaa.inc 文件必须与汇编源文件位于同一目录(或者你必须在 #include "aoaa.inc" 语句中提供文件的适当路径)。如果头文件不在当前目录,Gas 会提示找不到文件并终止汇编。还要记得在使用 #include "aoaa.inc" 时,使用 .S 后缀命名你的汇编源文件,否则 GCC 不会对这些文件运行 CPP。

1.6 ARM64 CPU 架构

到目前为止,你已经看到了成对的 Gas 程序,它们能够编译并运行。然而,这些程序中出现的语句目前仅仅是在将控制权返回给操作系统。在你学习一些真正的汇编语言之前,你需要了解 ARM CPU 家族的基本结构,这样你才能理解机器指令。

ARM CPU 家族通常被归类为冯·诺依曼架构机器。冯·诺依曼计算机系统包含三个主要组成部分:中央处理单元(CPU)内存输入/输出(I/O)设备。这三个组件通过系统总线(由地址、数据和控制总线组成)相互连接。图 1-1 展示了这种关系。

图 1-1:冯·诺依曼计算机系统框图

CPU 通过在地址总线上放置一个数字值来选择内存或 I/O 设备端口位置,从而与内存和 I/O 设备通信,每个位置都有一个唯一的二进制数字地址。然后,CPU、内存和 I/O 设备通过将数据放置在数据总线上相互传递数据。控制总线包含决定数据传输方向(到/从内存和到/从 I/O 设备)的信号。

1.6.1 ARM CPU 寄存器

ARM CPU 寄存器分为两类:通用寄存器特殊用途内核模式寄存器。特殊用途寄存器是为编写操作系统、调试器及其他系统级工具而设计的。这类软件的构建远远超出了本文的范围。

ARM64 支持 32 个通用 64 位寄存器(命名为 X0 至 X31)和 32 个通用 32 位寄存器(命名为 W0 至 W31)。这并不意味着总共有 64 个寄存器;相反,32 位寄存器覆盖了每个 64 位寄存器的低 32 位部分。(第二章将更深入地讨论低位(LO)部分。) 修改其中一个 32 位寄存器也会修改对应的 64 位寄存器,反之亦然,具体请参见图 1-2。

图 1-2:ARM 上的 32 位和 64 位寄存器

对于刚接触汇编语言的人来说,通常会惊讶于在 ARM64 上所有的计算都涉及一个寄存器。例如,要将两个变量相加,并将和存储到第三个变量中,你必须将其中一个变量加载到寄存器中,将第二个操作数加到寄存器中的值,然后将寄存器的值存储到目标变量中。寄存器几乎参与了每一个计算过程,因此它们在 ARM64 汇编语言程序中非常重要。

虽然这些寄存器被称为通用寄存器,但其中有一些具有特殊用途:

  • X31,在代码中通常称为SP,是栈指针,因为它用于在 ARM 上维护硬件栈(另一个非 RISC 或精简指令集计算机的特性),始终作为 64 位寄存器使用。由于它被用作栈指针,因此在大多数代码中,SP 不能用于其他用途。此寄存器只能通过少数指令访问。

  • XZR/WZR 寄存器(硬件上也称为 X31/W31)被称为零寄存器。它在读取时总是返回 0,是程序中获取常量 0 的便捷方式。

  • 寄存器 X30 是链接寄存器,通常用LR而不是 X30 来表示。ARM CPU 使用这个寄存器保存返回地址,当代码执行函数调用时。(第五章将更详细地讨论 LR。) 此寄存器始终在 64 位模式下访问。虽然理论上你可以将 X30/W30 作为通用寄存器使用,但应该避免这样做,因为函数调用会清除此寄存器中的值。

  • 尽管这个特殊用途不是硬件强制要求的,但大多数软件将 X29 用作 64 位的帧指针(FP)。软件通常使用这个寄存器来访问函数参数和局部变量。从技术上讲,你可以使用任何通用寄存器来实现这一目的,但使用 X29/FP 是惯例。

  • Apple 为其内部用途保留了 X18 寄存器。为 macOS、iOS、iPadOS 等编写的程序不得使用此寄存器。由于还有 29 个其他寄存器可用,本书中的示例不使用 X18,即使是 Linux 示例也是如此。

除了 32 个通用寄存器,ARM64 CPU 还拥有两个额外的特殊用途寄存器,用户程序可以访问:32 位的处理器状态(PSTATE)寄存器和 64 位的程序计数器(PC)寄存器。PC 寄存器始终包含正在执行的机器指令的地址。由于指令始终是 32 位长,CPU 在每次执行完一条指令并进入下一条指令时,会将该寄存器的值增加 4(有关此活动的更多内容,请参见第二章)。

注意

32 位 ARM CPU 将 PSTATE 寄存器称为 CPSR PSR。你可能会在各种文档中看到这些名称的引用。

PSTATE 寄存器宽度为 32 位(其中只有 16 位在写作时被使用),实际上它只是由一组单独的布尔标志组成。其布局如图 1-3 所示。

图 1-3:PSTATE 寄存器布局

大多数用户应用程序仅使用 PSTATE 寄存器中的 N、Z、C 和 V 位。这些位也称为条件码,它们具有以下含义:

N    负数(符号)标志,当指令产生负结果时设置

Z    零标志,当指令产生零结果时设置

C    进位标志,发生无符号算术溢出时设置

V    溢出标志,发生带符号算术溢出时设置

其余大多数标志对用户程序不可访问或几乎没有用处。UAO 和 PAN 控制 CPU 的访问特性,允许用户程序访问内核内存。SS 是调试时的单步控制位。IL 是非法指令标志,当 CPU 执行非法指令时设置。D、A、I 和 F 是中断标志。cEL 选择异常级别,通常用户模式为 00。SPS 选择要使用的堆栈指针(内核与用户模式)。

除了 32 个通用寄存器,ARM64 还提供了 32 个浮点寄存器和向量寄存器,用于处理非整数算术运算。第六章和第十一章在讨论浮点运算和单指令/多数据(SIMD)操作时,会更详细地讨论这些寄存器。

1.6.2 内存子系统

一台运行现代 64 位操作系统的典型 ARM64 处理器可以访问最多 2⁴⁸个内存位置,约为 256TB——这可能远远超过你的任何程序所需的内存。由于 ARM64 支持字节寻址内存,基本的内存单元是字节,足以容纳一个字符或一个非常小的整数值(在第二章中有进一步讨论)。

因为 2⁴⁸ 是一个非常大的数字,以下讨论使用的是 32 位 ARM 处理器的 4GB 地址空间。将其扩展后,相同的讨论适用于 64 位 ARM 处理器。

注意

虽然 ARM64 在软件中支持 64 位地址,但硬件仅支持 48 到 52 位地址用于虚拟内存操作。大多数操作系统将其限制为 48 位。

将内存视为一个线性字节数组。第一个字节的地址是 0,最后一个字节的地址是 2³² – 1。对于 ARM 处理器,以下伪 Pascal 数组声明是内存的一个很好的近似:

Memory: array [0..4294967295] of byte; 

C/C++ 和 Java 用户可能更喜欢以下语法:

byte Memory[4294967296]; 

要执行等同于 Pascal 语句 Memory [125] := 0; 的操作,CPU 将值 0 放到数据总线中,将地址 125 放到地址总线中,并且触发写入线(通常是将该线设置为 0),如 图 1-4 所示。

图 1-4:内存写操作

要执行等同于 CPU := Memory [125]; 的操作,CPU 将地址 125 放到地址总线中,触发读取线(因为 CPU 正在从内存读取数据),然后从数据总线中读取相应的数据(见 图 1-5)。

图 1-5:内存读取操作

本讨论仅适用于访问内存中的单个字节。要存储大于单字节的值,如半字(2 字节)和(4 字节),ARM 使用一系列连续的内存位置,如 图 1-6 所示。内存地址是每个对象的第一个字节的地址(即最低地址)。

图 1-6:内存中的字节、半字和字存储

ARM64 通常支持非对齐内存访问,意味着 CPU 可以在内存的任何地址读取或写入任意大小的对象——字节、半字、字或双字(dword)。然而,某些指令要求内存访问在传输的自然大小上对齐。通常,这意味着 16 位、32 位和 64 位内存访问必须在 2、4 或 8 的倍数地址上进行;否则,CPU 可能会引发异常。无论是否发生异常,CPU 通常能更快地访问在自然边界上对齐的内存位置。

现代 ARM 处理器不会直接连接到内存。相反,CPU 上有一个特殊的内存缓冲区,称为缓存(发音为“cash”),它充当 CPU 和主内存之间的高速中介。你将在第三章中学习如何设置内存对象的对齐方式以及缓存对数据对齐的影响。

1.7 在 Gas 中声明内存变量

在汇编语言中使用数字地址引用内存是可行的,但很痛苦且容易出错。与其让程序说,“给我内存位置 192 中保存的 32 位值和内存位置 188 中保存的 16 位值”,不如说,“给我元素计数(elementCount)和端口号(portNumber)的内容”。使用变量名而非内存地址,使程序更容易编写、阅读和维护。

要创建(可写的)数据变量,必须将它们放置在 Gas 源文件的数据部分中,该部分通过 .data 指令定义。.data 指令告诉 Gas,接下来的所有语句(直到下一个 .text 或其他定义节的指令)将定义数据声明,并将这些声明分组到内存的读/写部分。

.data 部分中,Gas 允许你通过一组数据声明指令声明变量对象。数据声明指令的基本形式是:

`label`: `directive value(s)`

其中,label 是合法的 Gas 标识符,directive 是以下列表中的某个指令之一:

`.byte    字节(8 位)值。一个或多个用逗号分隔的 8 位表达式出现在操作数字段中(值)。

`.hword .short .2byte    半字(16 位)值。一个或多个用逗号分隔的 16 位表达式出现在操作数字段中。

`.word .4byte    字(32 位)值。一个或多个用逗号分隔的 32 位表达式出现在操作数字段中。

.quad**,** .8byte    双字(64 位)值。一个或多个用逗号分隔的 64 位表达式出现在操作数字段中。.quad是 ARM64 的一个不幸的误称,因为 64 位值实际上是双字,而不是四字(在 ARM 中,四字是 128 位)。这个术语源自 x86 和 68000 汇编语言时代的“quad word”。为了避免混淆,本书使用.dword指令代替.quad`。

.dword    在 *aoaa.inc* 包含文件中出现的 .dword宏是.quad指令的同义词,它为每个操作数发出 8 字节(64 位)。使用.dword比使用.quad` 更好。必须包含 aoaa.inc 文件才能使用此指令。

.octa    八字(oword,128 位/16 字节)值。一个或多个用逗号分隔的 128 位表达式出现在操作数字段中。.octa是 ARM64 的一个不幸的误称,因为 128 位值实际上是四字(quad word),而不是“八字”(在 ARM 中,八字是 256 位)。为了避免混淆,本书避免使用.octa指令,改为使用.qword`。

.qword    这是在 *aoaa.inc* 包含文件中出现的一个宏。它是 .octa` 指令的同义词,并为每个操作数发出 16 字节。必须包含 aoaa.inc 文件才能使用此指令。

`.ascii    字符串值。一个字符串常量(用引号括起来)出现在操作数字段中。请注意,Gas 不会在此字符串后加上 0 字节来终止字符串。

.asciz    零终止字符串值。一个单一的字符串常量(用引号括起来)出现在操作数字段中。Gas 将在字符串操作数的最后一个字符后发出 0。

.float    单精度浮动点值。一个或多个用逗号分隔的 32 位单精度浮动点表达式出现在操作数字段中。

.double    双精度浮动点值。一个或多个用逗号分隔的 64 位双精度浮动点表达式出现在操作数字段中。

Gas 为此列表中的某些指令提供了额外的同义词;请参阅第 1.12 节“更多信息”中的第 43 页链接。

以下是一些有效的 Gas 数据声明示例:

byteVar:    .byte   0 
halfVar:    .hword  1,2  // Actually reserves 2 half-words 
wordVar:    .word   -1 
dwordVar:   .dword  123456789012345 
str1:       .ascii  "Hello, world!\n"  // Uses C-style escape for newline 
str2:       .asciz  "How are you?\n"   // Sequences are legal. 
pi:         .float  3.14159 
doubleVar:  .double 1.23456e-2 

每当你以这种方式声明变量时,Gas 将会将输出目标代码文件中的当前位置与行首的标签关联。然后,它会将适当大小的数据值发出到该位置的内存中,并通过每个操作数发出时的大小来调整汇编器的位置计数器(该计数器跟踪当前的位置)。

这些数据声明指令中的标签字段是可选的。如果没有包含标签,Gas 将简单地在操作数字段中发出数据,从当前位置计数器开始,并在之后递增位置计数器。这在你想将控制字符或特殊的 Unicode 字符插入字符串时非常有用:

longStr:   .ascii "A bell character follows this string"
           .byte  7, 0   // Bell (7) and zero termination 

Gas 允许在引号字符串中使用 C 风格的转义序列。尽管 Gas 不支持完整的转义字符集,但它支持以下转义序列:

\b        退格符(0x08)

\n        换行符/换行(0x0A)

\r        回车符(0x0D)

\t        制表符(0x09)

\f        换页符(0x0C)

\        反斜杠字符

\nnn    其中 nnn 是一个三位八进制值;将该值发出到代码流中。

\xhh    其中 hh 是一个两位十六进制值;将该值发出到代码流中。

Gas 不支持\a, \e, \f, \v, ', ", ?, \uhhhh 或\Uhhhh 转义序列。

1.7.1 将内存地址与变量关联

使用像 Gas 这样的汇编器时,你不必担心数字内存地址。一旦你在 Gas 中声明了一个变量,汇编器就会将该变量与一组唯一的内存地址关联。例如,假设你有以下声明部分:

 .data
i8:   .byte   0
i16:  .hword  0
i32:  .word   0
i64:  .dword  0

Gas 将找到内存中未使用的 8 位字节,并将其与 i8 变量关联;它还会将一对连续的未使用字节与 i16 关联,将 4 个连续的未使用字节与 i32 关联,8 个连续的未使用字节与 i64 关联。你将始终通过它们的名称来引用这些变量,通常无需关心它们的数字地址。不过,请注意 Gas 会为你完成这些操作。

当 Gas 处理 .data 区段中的声明时,它会为每个变量分配连续的内存地址。假设 i8(在之前的声明中)是 101 号内存地址,Gas 会将出现在表 1-1 中的地址分配给 i8、i16、i32 和 i64。

表 1-1:变量地址分配

变量 内存地址
i8 101
i16 102(i8 地址加 1)
i32 104(i16 地址加 2)
i64 108(i32 地址加 4)

从技术上讲,Gas 将偏移量分配给 .data 区段中的变量。Linux/macOS 在程序加载到内存时,会将这些偏移量转换为物理内存地址。

每当你在数据声明语句中有多个操作数时,Gas 会按照它们在操作数字段中出现的顺序将值输出到连续的内存位置。与数据声明相关的标签(如果有的话)将与第一个(最左边)操作数的值的地址相关联。有关更多详情,请参见第四章。

1.7.2 对齐变量

如前所述,如果你的变量在自然边界上对齐(与对象的大小对齐),程序可能会运行得更快。对齐是通过 .align 指令完成的,你在列表 1-1 中已经见过这种指令。

字节变量不需要任何对齐。使用 .align 1 指令将半字对齐到偶数地址(2 字节边界);记住,Gas 会将下一条语句对齐到一个边界,该边界等于 2^n,其中 n 是 .align 指令的操作数。对于字,使用 .align 2 指令。对于双字(.dword),使用 .align 3 指令。

例如,让我们回到之前给出的声明:

 .data 
i8:  .byte   0 
i16: .hword  0 
i32: .word   0 
i64: .dword  0 

在每个声明前(除了 i8)粘贴 .align 指令会让代码变得杂乱,且更难阅读:

 .data 
i8:  .byte   0  // No alignment necessary for bytes 
     .align  1 
i16: .hword  0 
     .align  2 
i32: .word   0 
     .align  3 
i64: .dword  0 

如果你的变量不需要按特定顺序声明,你可以通过先声明最大的变量,然后按递减顺序排序剩余的变量来清理代码。如果这样做,你只需要对齐声明列表中的第一个变量:

 .data 
     .align  3 
i64: .dword  0 
i32: .word   0 
i16: .hword  0 
i8:  .byte   0  // No alignment necessary for bytes 

由于 i64 声明紧随 .align 3 指令之后,i64 的地址将在 8 字节边界对齐。由于 i32 紧跟 i64 之后,它也将被 8 字节对齐(这当然也是 4 字节对齐)。这是因为 i64 在 8 字节边界对齐并占用 8 字节;因此,i64 后面的地址(i32 的地址)也会被 8 字节对齐。

同时,由于 i16 紧随 i32 之后,它将在 4 字节边界对齐(这也是一个偶数地址)。i8 的对齐方式无关紧要,但它恰好处于偶数地址,因为它紧随 i16 之后,i16 已经在 4 字节边界对齐并占用了 2 字节。

注意

Gas 还提供了一个 .balign 指令,它的操作数必须是 2 的幂(1、2、4、8、16,...),用于直接指定对齐值,而不是作为 2 的幂。虽然本书使用 .align 指令,因为它是原始指令,但如果你更喜欢的话,也可以使用 .balign*。

字符串是字节的序列,因此它们的对齐通常无关紧要。然而,确实可以编写高性能的字符串函数,这些函数一次处理八个或更多字符。如果你有访问这样的库代码的权限,如果你的字符串按 8 字节边界对齐,它可能会运行得更快。

当然,浮点数和双精度数应该在 4 字节和 8 字节边界上对齐,以获得最佳性能。实际上,正如你将在 第十一章 中看到的,有时 16 字节对齐也更好。

1.7.3 在 Gas 中声明命名常量

Gas 允许你使用 .equ 指令声明显式常量。一个 显式常量 是一个符号名称(标识符),Gas 将其与一个值关联。在程序中每次出现该符号时,Gas 会直接替换它的值。

一个显式常量声明的格式如下:

.equ `label`, `expression`

在这里,label 是一个合法的 Gas 标识符,expression 是一个常量算术表达式(通常是一个单一的字面常量值)。以下示例将符号 dataSize 定义为 256:

.equ dataSize, 256

常量声明,或者在 Gas 术语中称为 equates,可以出现在 Gas 源文件中的任何位置,位于第一次使用之前:可以在 .data 部分、.text 部分,甚至可以在任何部分之外。

一旦你使用 .equ 定义了一个常量符号,它就不能在汇编过程中进一步修改。如果你需要在汇编过程中重新分配与标签关联的值(参见 第十三章 了解你为什么需要这样做),可以使用 .set 指令:

 .set valueCanChange, 6
      .
      .  // valueCanChange has the value 6 here.
      .
    .set valueCanChange, 7

// From this point forward, valueCanChange has the value 7.

等式可以指定文本参数以及数字常量。

由于如果文件名后缀是 .S,Gas 会通过 CPP 运行你的源文件,你也可以使用 CPP 的 #define 宏定义来创建命名常量。尽管 .equ 指令可能是更好的选择,但 C 宏形式有一些优势,比如允许任意文本替代,而不仅仅是数字表达式替代。有关更多信息,请参见 第十三章。

1.7.4 在 Gas 中创建寄存器别名并进行文本替换

当你开始编写更复杂的 ARM 汇编语言程序时,你会发现那 32 个通用寄存器名(X0 到 X30 和 SP)使得它们在程序中的值难以理解。从 BASIC 只支持像 A0、A1、B2 和 Z3 这样的变量名已经过去几十年了。为了避免通过使用无意义的两字符名称回到那些日子,Gas 提供了一种在程序中创建寄存器名称的更有意义的别名的方法:.req 指令。

.req 指令的语法是:

`symbolicName`  .req  `register`

其中,symbolicName 是任何有效的 Gas 标识符,register 是 32 位或 64 位寄存器名称之一。在源文件中这条语句之后,如果你使用 symbolicName 替代 register,Gas 将自动将该寄存器替换为名称。

遗憾的是,.req 指令仅适用于创建寄存器别名;你不能将其用作通用的文本替换工具。然而,如果你将汇编语言源文件命名为 .S,Gas/GCC 将首先通过 CPP 处理你的源文件。这使得你可以在汇编源文件中嵌入 C/C++ 的 #define 语句,并且 CPP 将乐意扩展你在这些语句中定义的任何符号,贯穿整个源文件。以下示例演示了如何使用 #define:

#define arrayPtr X0

// From this point forward, you can use arrayPtr in place of X0.

通常,你会使用 .req 来定义寄存器别名,而使用 #define 来进行其他文本替换,尽管我个人更倾向于在本书中使用 #define 语句来处理这两种情况。由于 #define 也接受参数,它具有灵活性。Gas 还支持通过进行文本替换;有关更多信息,请参见第十三章。

1.8 基础 ARM 汇编语言指令

到目前为止,本章中的编程示例仅包含使用 ret 指令的函数。本节将介绍一些其他指令,帮助你开始编写更有意义的汇编语言程序。

1.8.1 ldr、str、adr 和 adrp

ARM 的一个显著的 RISC 特性是其采用的加载/存储架构。所有的计算活动都在 ARM 的寄存器中进行;唯一访问主内存的指令是那些从内存加载值或将值存储到内存的指令。

虽然 ARM64 有许多通用寄存器来存储变量值(因此可以避免使用内存),但大多数应用程序使用的变量数据量超过了所有寄存器所能容纳的大小。对于数组、结构体和字符串等较大的对象,这一点尤其如此。此外,编程约定——即后面章节中讨论的应用程序二进制接口(ABI)——通常保留了许多 ARM 寄存器,因此这些寄存器无法用于长时间保存应用程序变量。所以,变量必须存放在主内存中,并通过这些 ldr(加载)和 str(存储)指令来访问。

这是加载和存储指令的通用语法

ldr{`size`}  `reg`, `mem`
str{`size`}  `reg`, `mem`

其中,size 要么不存在,要么是字符序列 b、h、sb、sh 或 sw 之一;reg 是 ARM 的 32 位或 64 位寄存器之一;mem 是一种内存寻址模式,指定从内存中哪里获取数据。ldr 指令从 mem 指定的内存位置加载 reg 指定的寄存器。str 指令将寄存器操作数中的值存储到内存位置。

第二章更深入地讨论了大小操作数,但本章大致忽略了 ldr 和 str 指令上的大小后缀。没有大小前缀时,reg 操作数决定操作的大小。如果 reg 是 Xn,则指令传输 64 位;如果是 Wn,则指令传输 32 位。

mem 操作数要么是程序中变量的名称,通常位于 .data 部分(仅限 Linux),要么是一个用方括号([])括起来的寄存器名称。在后一种情况下,寄存器保存的是要访问的内存位置的数值地址。有关 mem 的更多信息,请参见第三章。

由于 macOS 要求你的应用程序必须以位置无关的方式编写(如我们在“Linux 与 macOS:位置无关可执行文件”一节中讨论的那样),你将无法使用以下形式的 ldr 指令:

ldr x0, i64   // i64 is a 64-bit variable declared
              // in the .data section by using .dword.

要访问 i64 变量,必须首先将其地址加载到 64 位寄存器中,然后通过使用寄存器间接寻址模式或 Xn 来访问该数据。为此,可以使用 adr 和 adrp 指令将你想要访问的变量的地址放入寄存器中:

adr  `reg`64, `mem`
adrp `reg`64, `mem`

在这里,reg64 是一个 64 位通用寄存器的名称,mem 是一种内存寻址模式,类似于全局变量的名称。adr 指令将 reg 加载为内存变量的地址,如果操作数仅是一个变量的名称(像前面的 i64),那么 adr 指令要求地址距离该指令的偏移量必须在 ±1MB 之内。adrp 指令将 64 位目标寄存器加载为包含内存对象的页(4,096 字节边界)。该值的低 12 位将全为 0。

由于 macOS 的 PIE 要求,它不太接受如下的指令:

ldr x0, i64

在 Mac 上,你必须使用寄存器间接寻址模式来访问全局变量。不幸的是

adr x1, i64

失败的原因是相同的:你不能指定全局变量的名称。

在本书中,为了在 macOS 下将全局变量的地址加载到寄存器中,我们将使用以下语句:

lea `reg`, `mem`

aoaa.inc 中包含的 lea(加载有效地址)宏将展开为两条指令(取决于你的操作系统)。这些指令将第二个操作数(mem)的地址加载到第一个操作数(reg)指定的 64 位寄存器中。你可以在任何包含了 aoaa.inc 的项目中使用 lea,它通常会在源文件的开头被引入。

如前所述,aoaa.inc 宏使得本书中的代码能够在不同操作系统之间移植。然而,一旦你掌握了基本的 ARM 汇编语言编程,你可以选择使用适合操作系统的特定代码,这样有时可能会更加高效。有关 lea 的更多细节,请参见第七章。

结束关于获取变量地址的讨论时,我们来总结一下如何使用 ldr 和 str 加载和存储值:

 .data
i64:    .dword  0  // This also requires the aoaa.inc file.
         .
         .
         .
// Load i64's value into X0:

        lea x0, i64
        ldr x0, [x0]
         .
         .
         .
// Store X0 into i64:

        lea x1, i64
        str x0, [x1]

当将变量的值加载到 X0 时,你可以先将 X0 加载为该变量的地址,然后从 X0 保存的地址间接加载 X0 的值。这样最终只使用一个寄存器。然而,在将数据存储到内存时,你需要第二个寄存器来保存地址(在这个例子中是 X1)。

如果你在代码的小段落内多次引用特定变量,最好只将其地址加载到寄存器中一次,并多次重用该寄存器的值,而不是每次都重新加载地址:

lea x1, i64
ldr x0, [x1]
 .
 .
 .
str x0, [x1]

当然,这意味着在寄存器保存 i64 的地址时,你不能将该寄存器用于其他任何目的。幸运的是,正因为这个原因,ARM64 拥有大量的寄存器。

1.8.2 mov

除了 ldr 和 str 指令外,mov 指令还处理两种额外的数据移动操作:在一对寄存器之间移动数据以及将常量复制到寄存器中。mov 的通用语法如下:

mov `reg`dest, `reg`src
mov `reg`dest, #`constant`

第一条 mov 指令将源寄存器(regsrc)中的数据复制到目标寄存器(regdest)中。这个指令相当于 C/C++语句 regdest = regsrc;。源寄存器和目标寄存器可以是任何通用寄存器,但它们必须具有相同的大小(32 位或 64 位)。

第二条 mov 指令将一个小的整数常量移动到目标寄存器中。作为指令一部分编码的常量被称为立即数常量,通常会以#字符为前缀(尽管 Gas 通常允许在指定字面数值常量时省略#)。第二章讨论了常量的限制,但现在假设任何小于±2,047 的常量都可以使用。

下面是两个 mov 指令的示例:

mov x1, x0   // X1 = X0
mov x2, #10  // X2 = 10

mov 指令有许多额外的变体,后续章节会深入讲解。例如,如果你遇到一个常量,无法通过单条 mov 指令加载到寄存器中,其他变体的 mov 指令允许你通过使用两到三条指令加载任何任意的 32 位或 64 位常量。与此同时,以下这种 ldr 指令的变体将把任何常量加载到寄存器中:

ldr `reg`, =`veryLargeConstant`

汇编器将简单地将 veryLargeConstant 存储在某个内存位置,然后将该内存位置的内容加载到指定的寄存器中。当你需要通过单条指令将大常量加载到寄存器中时,可以使用这个方便的伪指令。

1.8.3 add 和 sub

add 和 sub 指令处理 ARM CPU 上的简单算术运算。这些指令有多种形式,在接下来的几章中会更深入地讨论。它们的基本形式如下:

add  `reg`dest, `reg`lsrc, `reg`rsrc // `reg`dest = `reg`lsrc + `reg`rsrc
add  `reg`dest, `reg`lsrc, #`const` // `reg`dest = `reg`lsrc + `const`
adds `reg`dest, `reg`lsrc, `reg`rsrc // `reg`dest = `reg`lsrc + `reg`rsrc
adds `reg`dest, `reg`lsrc, #`const` // `reg`dest = `reg`lsrc + `const`
sub  `reg`dest, `reg`lsrc, `reg`rsrc // `reg`dest = `reg`lsrc - `reg`rsrc
sub  `reg`dest, `reg`lsrc, #`const` // `reg`dest = `reg`lsrc - `const`
subs `reg`dest, `reg`lsrc, `reg`rsrc // `reg`dest = `reg`lsrc - `reg`rsrc
subs `reg`dest, `reg`lsrc, #`const` // `reg`dest = `reg`lsrc - `const`

在这里,regdest、reglsrc 和 regrsrc 是 32 位或 64 位寄存器(对于给定的指令,它们必须具有相同的大小),const 是一个范围在 0 到 4,095 之间的立即数常量。稍后你会学习如何指定更大的常量,但这些形式对于接下来几章的示例程序已经足够。

注意

一些汇编器允许范围从 -4,095 到 +4,095,并在立即数为负时交换 add sub 指令。

带有 s 后缀的指令会影响条件码标志。它们根据以下列表中指定的条件设置标志:

N    如果算术操作产生负结果(高位或 HO 位被置位),则设置;如果产生非负结果(HO 位清零),则清除。

Z    如果算术操作产生 0 结果,则设置;如果产生非零结果,则清除。

C    如果加法操作产生无符号溢出(HO 位溢出),则设置;如果减法操作产生借位(无符号下溢),则清除,否则设置。

V    如果算术操作产生符号溢出(下一个高位以外的位溢出),则设置。

以下指令将源操作数取反,因为它们将源寄存器从 0 中减去(记住 WZR 和 XZR 是零寄存器,读取时返回 0):

sub `reg`dest32, wzr, `reg`src32 // `reg`dest32 = - `reg`src32
sub `reg`dest64, xzr, `reg`src64 // `reg`dest64 = - `reg`src64

Gas 为这些指令提供了同义词:

neg  `reg`dest32, `reg`src32 // Negate instruction, no flags
negs `reg`dest32, `reg`src32 // Negate instruction, w/flags
neg  `reg`dest64, `reg`src64 // Negate instruction, no flags
negs `reg`dest64, `reg`src64 // Negate instruction, w/flags

这些形式更容易阅读。

1.8.4 bl, blr 和 ret

调用过程和函数是通过 bl(分支并链接)和 blr(通过寄存器分支并链接)指令处理的。以下是它们的语法:

bl  `label`
blr X`n`

其中 label 是 .text 段中代码前的语句标签,Xn 代表其中一个 64 位寄存器。这两个指令将下一个指令的地址(bl 或 blr 指令后的指令)复制到链接寄存器 (LR/X30) 中,然后将控制权转移到目标标签或由 Xn 内容指定的地址。

bl 指令确实有一个小的限制:它只能将控制权转移到当前指令 ±128MB 范围内的语句标签。这对于你编写的任何函数来说通常已经足够了。理论上,如果操作系统将代码加载到另一个段(除了 .text),它可能会被放置得足够远,以至于超出了这个范围。如果发生这种情况,操作系统的链接器可能会抱怨。由于此书通常将所有代码放置在 .text 段内,所以超出此限制的程序非常罕见。

blr 指令将 Xn 中的完整 64 位地址复制到 PC 中(在将下一个指令的地址复制到 LR 后)。因此,blr 不受 bl 指令的范围限制。如果你在使用 bl 时遇到范围限制,可以通过以下序列克服它:

lea x0, `farAwayProcedure`
blr x0

这将把 farAwayProcedure 的地址加载到 X0 中(无论它在内存中的位置如何),然后通过 blr 将控制权转移到该过程。

ret 指令在到目前为止的几个示例中都有出现。它将 LR (X30) 寄存器的内容复制到 PC 中。假设 LR 在执行 bl 或 blr 指令后被加载了一个值,那么这将把控制权返回到 bl/blr 后的指令。

bl、blr 和 ret 指令有一个问题:ARM 架构只用 LR 寄存器跟踪单一的子程序调用。考虑以下代码片段:

someFunc:
        ret
         .
         .
         .
main:
        bl someFunc
        ret

当操作系统调用主程序时,它将 LR 寄存器加载为返回地址,指向操作系统。通常,当主程序完成执行时,它的 ret 指令会将控制转移到这个位置。然而,在这个例子中情况并非如此:当主程序开始执行时,它会立即通过 bl 指令调用 someFunc。这条指令将返回地址(主程序 ret 指令的地址)复制到 LR 寄存器中,覆盖了当前存储在其中的操作系统返回地址。当 someFunc 执行返回指令时,它将控制权返回给主程序。

从 someFunc 返回后,主程序执行 ret 指令。然而,LR 寄存器现在包含 someFunc 调用的返回地址,即主程序中 ret 指令的地址,因此控制转移到该位置,重新执行 ret。LR 寄存器的值没有改变;它仍然指向该 ret 指令,这意味着这段代码进入了一个无限循环,不断执行返回并将控制权转移回返回指令(LR 继续指向该位置)。

第三章讨论了解决此问题的高层次方案。暂时,我们必须在调用 someFunc 之前在主程序中保存 LR 寄存器的值。一个快速且粗略的方法是将其复制到另一个(主程序未使用的)寄存器中,并在最终返回之前恢复 LR:

someFunc:
        ret
         .
         .
         .
main:
        mov x1, lr
        bl someFunc
        mov lr, x1
        ret

这段代码将返回地址(保存在 LR 中)保存到 X1 寄存器,并在从 someFunc 返回后恢复它(对 someFunc 的调用覆盖了 LR 中的值)。

通常,将返回地址保存在 X1 寄存器中是一个不好的主意,因为 ARM 的设计者将 X1 保留用于传递参数。(在这个例子中使用 X1 是可行的,因为 someFunc 没有任何参数,它只是返回到调用者。)下一部分将更深入地讨论哪些寄存器被保留用于不同的目的。

1.9 ARM64 应用二进制接口

一个 CPU 的应用二进制接口(ABI)描述了程序如何使用寄存器、在函数间传递参数、表示数据以及许多其他约定。它的主要目的是提供编程语言和系统之间的互操作性。例如,ARM64 的 ABI 描述了允许 C/C++程序调用用 Swift、Pascal 和其他语言编写的函数的约定。由于 GCC(和 Clang)编译器遵循这些规则,因此你也必须遵循这些规则,以便在汇编语言代码和用 C/C++等高级语言编写的代码之间传递信息。

ABI 是一种约定,而不是绝对规则。它是被调用代码和调用代码之间的合约。当编写自己的汇编语言函数以供自己的汇编语言代码调用时,您没有义务使用 ABI,并且可以使用任何您喜欢的代码间通信方案。然而,如果从您的汇编函数调用 C/C++ 代码,或者如果您的汇编代码被 C/C++ 调用,您必须遵循 ARM64 ABI。由于本书使用了大量的 C/C++ 和汇编代码混合,理解 ARM64 ABI 对我们至关重要。

1.9.1 寄存器使用

ARM64 ABI 保留了其 32 个通用寄存器中的一些用途,并定义了寄存器是易失性(即您不必保留其值)还是非易失性(即您必须在函数内保留其值)的情况。表 1-2 描述了 32 个 ARM 寄存器的特殊用途和易失性。

表 1-2:ARM64 ABI 寄存器约定

寄存器 易失性 特殊含义
X0/W0 在这里传递参数 1,返回函数结果。寄存器 X0 到 X7 也可以用作临时变量/本地变量,如果未用作参数的话。
X1/W1 在这里传递参数 2,返回函数结果。
X2/W2 在这里传递参数 3,返回函数结果。
X3/W3 在这里传递参数 4,返回函数结果。
X4/W4 在这里传递参数 5,返回函数结果。
X5/W5 在这里传递参数 6,返回函数结果。
X6/W6 在这里传递参数 7,返回函数结果。
X7/W7 在这里传递参数 8,返回函数结果。
X8/W8 指向大型函数返回结果(例如,通过值返回的大型 C 结构体)。

| X9/W9 X10/W10

X11/W11

X12/W12

X13/W13

X14/W14

X15/W15 | 是 是

是 | 可用作临时变量/本地变量。 |

X16/W16/IP0 是,但是 ... 您可以将此寄存器用作临时变量,但其值可能会在控制转移指令的执行过程中发生变化;系统链接器/加载器可能会使用此寄存器创建表面层,也称为跳板(更多信息请参见第七章)。
X17/W17/IP1 是,但是 ... 您可以将此寄存器用作临时变量,但其值可能会在控制转移指令的执行过程中发生变化;系统链接器/加载器可能会使用此寄存器创建表面层,也称为跳板(更多信息请参见第七章)。
X18/W18/Plat 无访问 此寄存器保留供操作系统使用,应用程序不能修改其值。在 macOS 下,绝对不能修改此寄存器;在 Linux 下,如果保留其值,可以使用此寄存器,但安全起见,应避免使用此寄存器。

| X19/W19 X20/W20

X21/W21

X22/W22

X23/W23

X24/W24

X25/W25

X26/W26

X27/W27

X28/W28 | 无 | 无

无 | 使用此寄存器的函数必须保存并恢复该寄存器的值,以便它在函数返回时包含其原始值。 |

X29/W29/FP 不适用 保留用于系统帧指针。
X30/W30/LR 不适用 保留用于保存函数返回地址。
SP /X31/W31 不适用 保留用于系统栈指针。

方便的是,当在函数中使用易失性寄存器时,你不必在函数内保存(保存并恢复)它们的值。然而,这也意味着你不能指望它们在你通过 blblr 调用的任何函数中保持其值。非易失性寄存器将在你调用的函数之间保持其值,但如果在函数内修改它们,你必须显式地保存其值。

1.9.2 参数传递和函数结果约定

第五章提供了关于汇编语言中参数传递和函数结果的完整讨论。然而,当调用用其他语言编写的函数(尤其是高级语言)时,你必须遵循该语言使用的约定。大多数高级语言使用 ARM ABI 作为参数传递的约定。

ARM ABI 使用寄存器 X0 到 X7 向函数传递最多八个整数参数。这些参数可以是 8 位、16 位、32 位或 64 位的实体。第一个参数传递在 X0 中,第二个在 X1 中,依此类推。如果传递少于八个参数,只需忽略该组中额外的寄存器。第五章讨论了如何传递超过八个参数以及如何传递大于 64 位的数据类型,包括数组和结构体。第六章介绍了如何将浮点值传递给函数。

你也可以在这些寄存器中返回函数结果。大多数函数将整数结果返回在 X0 中。如果你返回一个较大的对象值,比如结构体、数组或字符串,通常使用 X8 来返回指向该数据对象的指针。第六章讨论了如何返回浮点函数结果。

寄存器 X0 到 X7 是易失性的,意味着你不能指望被调用的函数在返回时保留原始的寄存器值。即使你没有使用所有八个寄存器来传递参数值,这也是成立的。如果你想在函数调用之间保留一个值,应该使用非易失性寄存器。

1.10 调用 C 库函数

本书中的所有编码示例迄今为止都直接返回给操作系统,表面上看似没有做任何事情。虽然理论上纯汇编语言程序是可以产生自己的输出的,但这需要大量的工作,且大部分超出了本书的范围。因此,本书调用了预写的 C/C++ 库代码来进行输入输出。本节讨论了如何实现这一点。

大多数使用库的汇编语言书籍通过调用现有的 应用程序编程接口 (APIs) 来与操作系统交互。这是一种合理的方法,但这种代码与特定操作系统紧密相关(有关示例,请参见第十六章)。本书则依赖于 C 标准库中的库函数,因为它在许多操作系统上都可用。

在大多数入门编程书籍中,提供的第一个编程示例通常是经典的“Hello, world!”程序。以下是用 C 语言编写的该程序:

#include <stdio.h>
int main(int argc, char **argv) 
{
   printf("Hello, world!\n"); 
}

除了实际的 printf() 语句外,迄今为止提供的汇编语言源文件已经实现了“Hello, world!”示例的目的:学习如何编辑、编译和运行一个简单的程序。

本书的大部分内容使用 C 的 printf() 函数来处理程序输出到控制台。这个函数需要一个或多个参数——即 可变长度 参数列表。第一个参数是 格式字符串 的地址。如果该字符串需要,额外的参数将提供要转换为字符串的数据显示。对于“Hello, world!”程序,格式字符串("Hello, world!\n")是唯一的参数。

C 标准库——以及所有 C 函数——遵循 ARM ABI。因此,printf() 期望其第一个参数,即格式字符串,在 X0 寄存器中。我们不是尝试将一个包含 14 个字符(包括换行符)的字符串传递给一个 64 位寄存器,而是将该字符串在内存中的地址传递过去。如果我们将字符串“Hello, world!\n”放置在 .text 区段中,并与程序一起(避免 CPU 将其当作代码执行),那么我们可以通过使用 lea 宏来计算该字符串的地址:

hwStr:   .asciz  "Hello, world!\n"
           . 
           . 
           . 
          lea    x0, hwStr 

一旦我们在 X0 寄存器中获取了该字符串的地址,调用 printf() 就可以将该字符串打印到标准输出设备上:

lea x0, hwStr 
bl  printf 

为了运行,本程序必须与 C 标准库以及类似清单 1-2 中的小型 C/C++ 程序链接。与其拿取那个程序,我将在清单 1-4 中创建一个稍微改进的版本,以供本书后续几乎所有示例程序使用。

// Listing1.4.cpp 
//
// Generic C++ driver program to call AoAA example programs 
// Also includes a "readLine" function that reads a string 
// from the user and passes it on to the assembly language 
// code 
//
// Need to include stdio.h so this program can call "printf"
// and stdio.h so this program can call strlen. 

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Extern "C" namespace prevents "name mangling" by the C++
// compiler: 

extern "C"
{
    // asmMain is the assembly language code's "main program":

    ❶ void asmMain(void); 

    // getTitle returns a pointer to a string of characters 
    // from the assembly code that specifies the title of that 
    // program (that makes this program generic and usable 
    // with a large number of sample programs in "The Art of 
    // ARM Assembly Language"):

  ❷ char *getTitle(void); 

    // C++ function that the assembly 
    // language program can call: 

❸ int readLine(char *dest, int maxLen); 

};

// readLine reads a line of text from the user (from the 
// console device) and stores that string into the destination 
// buffer the first argument specifies. Strings are limited in 
// length to the value specified by the second argument 
// (minus 1). 
//
// This function returns the number of characters actually 
// read, or -1 if there was an error. 
//
// If the user enters too many characters (maxLen or 
// more), this function returns only the first maxLen - 1 
// characters. This is not considered an error. 

int readLine(char *dest, int maxLen) 
{
    // Note: fgets returns NULL if there was an error, else 
    // it returns a pointer to the string data read (which 
    // will be the value of the dest pointer): 

    char *result = fgets(dest, maxLen, stdin); 
    if(result != NULL) 
    {
        // Wipe out the newline character at the 
        // end of the string: 

        int len = strlen(result); 
        if(len > 0) 
        {
            dest[len - 1] = 0; 
        }
        return len; 
    }
    return -1; // If there was an error 
}

int main(void) 
{
    // Get the assembly language program's title: 

    char *title = getTitle();

    printf("Calling %s:\n", title); 
    asmMain();
    printf("%s terminated\n", title); 

}

本程序相较于清单 1-2 中的程序,新增了一些功能。首先,汇编语言函数的名称已更改为 asmMain() ❶,即汇编语言主程序。此代码还需要第二个汇编函数,getTitle() ❷。该函数由汇编语言源代码提供,返回一个指向零终止字符串的指针,该字符串包含程序的标题。程序在调用 asmMain() 之前和之后都会显示该标题。

readLine() 函数出现在 C 程序中,它读取用户输入的一行文本,并将该文本存储到调用者指定的缓冲区 ❸。你可以从示例汇编代码中调用这个函数,这样就不必在汇编中编写该函数(这是更适合在 C 中完成的琐碎工作)。你将在后续章节中看到该函数调用的示例。

这个文件(在在线代码中显示为Listing1-4.cppc.cpp)要求汇编代码提供一个 getTitle() 函数,返回一个字符串的地址,以便 C 程序可以显示该名称。这个字符串被嵌入在汇编语言源文件中,因为本书中的大多数程序只使用一个版本的c.cpp。getTitle() 函数在每个程序中都是相同的。

getTitle: 
            lea     x0, title  // C expects pointer in X0\. 
            ret 

其中 title 是一个在程序的其他地方出现的零终止字符串(通常在 .data 区段)。该声明通常采用以下形式:

title:      .asciz "Listing1-5"

getTitle 函数返回该字符串的地址给 c.cpp 程序。紧跟 .asciz 指令后的字符串通常是汇编语言源文件的名称(在此示例中,我使用了 Listing1-5)。

1.10.1 在多个操作系统下汇编程序

此时,我们可以轻松地为 Linux 或 macOS 编写一个“Hello, world!”程序,但每个操作系统的程序会略有不同。为了避免为每个操作系统使用不同的包含文件,我已经修改了aoaa.inc,使其查找几个符号定义:isMacOS 和 isLinux。两个符号必须通过 CPP 的 #define 声明来定义,其中一个必须为真(1),另一个为假(0)。aoaa.inc 文件使用这些符号来调整文件中适用于特定操作系统的定义。

理论上,我们可以使用如下代码来定义这些符号:

#define isMacOS (1)
#define isLinux (0)
#include "aoaa.inc"

然而,这将迫使每个示例程序都有两个版本,一个用于 macOS(如刚才给出的示例),另一个用于 Linux,包含以下语句:

#define isMacOS (0)
#define isLinux (1)
#include "aoaa.inc"

GCC 有一个更优的命令行选项,可以让你定义一个预处理器符号并为其赋值:

-D `name`=`value`

通过这种方式,以下命令将在汇编 source.S 文件之前自动定义符号:

g++ -D isMacOS=1 source.S
g++ -D isLinux=1 source.S

我们可以通过这种方式在命令行中指定操作系统,以便源文件(source.Saoaa.inc)在 macOS 或 Linux 下无需任何修改。为了避免在汇编程序时需要额外的输入,我们将使用一种命令行程序,称为shell 脚本

在编写这个用途的 shell 脚本时,我还进一步自动化了构建过程。该脚本名为build,接受一个示例文件的基本名称(不带后缀),并自动删除任何具有该基本名称的现有目标文件或可执行文件(在 Unix 术语中为clean操作)。然后,它确定build运行所在的操作系统,并自动生成适当的 GCC 命令行以构建示例。

例如,要构建一个名为 example.S 的文件,你将执行以下命令:

./build example

在 Linux 下,这将生成以下命令:

g++ -D isLinux=1 -o example c.cpp example.S

在 macOS 下,它将生成以下内容:

g++ -D isMacOS=1 -o example c.cpp example.S

build脚本还支持几个命令行选项:-c 和 -pie。-c(仅编译)选项生成以下命令行,它只将汇编文件汇编为目标文件;它不会编译c.cpp,也不会生成可执行文件:

./build -c example

这将根据需要执行以下命令:

g++ -c -D isMacOS=1 -o example.o example.S

或者

g++ -c -D isLinux=1 -o example.o example.S

-pie 选项仅适用于 Linux。它会发出相应的命令,告诉 Linux 生成一个位置无关的可执行文件(默认情况下,Linux 会生成一个位置相关的可执行文件)。由于 macOS 的汇编器始终生成 PIE 代码,因此在 macOS 下此选项会被忽略。

对于好奇的人,我已将这个 shell 脚本的文本提供在build文件中,没有进一步的注释,因为编写 shell 脚本超出了本书的范围:

#!/bin/bash
#
# build
#
# Automatically builds an Art of ARM Assembly
# example program from the command line
#
# Usage:
#
#    build {options} fileName
#
# (no suffix on the filename.)
#
# options:
#
#    -c: Assemble .S file to object code only.
#    -pie: On Linux, generate a PIE executable.

fileName=""
compileOnly=" "
pie="-no-pie"
cFile="c.cpp"
lib=" "
while [[$# -gt 0]]
do

    key="$1"
    case $key in

        -c)
        compileOnly='-c'
        shift
        ;;

        -pie)
        pie='-pie'
        shift
        ;;

        -math)
        math='-lm'
        shift
        ;;

        *)
        fileName="$1"
        shift
        ;;
    esac
done

# If -c option was provided, only assemble the .S
# file and produce an .o output file.
#
# If -c not specified, compile both c.cpp and the .S
# file and produce an executable:

if ["$compileOnly" = '-c']; then
    objectFile="-o $fileName".o
    cFile=" "
else
    objectFile="-o $fileName"
fi

# If the executable already exists, delete it:

if test -e "$fileName"; then
    rm "$fileName"
fi

# If the object file already exists, delete it:

if test -e "$fileName".o; then
    rm "$fileName".o
fi

# Determine what OS you're running under (Linux or Darwin [macOS]) and
# issue the appropriate GCC command to compile/assemble the files.

unamestr=$(uname)
if ["$unamestr" = 'Linux']; then
    g++  -D isLinux=1 $pie $compileOnly $objectFile  $cFile $fileName.S $math
elif ["$unamestr" = 'Darwin']; then
    g++  -D isMacOS=1  $compileOnly $objectFile $cFile  $fileName.S -lSystem $math
fi

如果你想了解其工作原理,可以查阅一本关于 GNU bash shell 解释器的书(请参见第 1.12 节,“更多信息”,在第 43 页)。

build脚本以电子形式提供,网址为<wbr>artofarm<wbr>.randallhyde<wbr>.com。在 Linux 或 macOS 系统的 bash 命令行中执行以下命令,以使此文件可执行:

chmod u+x build

这使得build脚本变为可执行文件。有关 chmod 命令的更多信息,请参见附录 D。

1.10.2 编写“Hello, World!”程序

现在,你已经完成了编写一个完整的“Hello, world!”程序的各个步骤,如 Listing 1-5 所示。

// Listing1-5.S
//
// The venerable "Hello, world!" program, written
// in ARM assembly by calling the C stdlib printf
// function
//
// aoaa.inc is the Art of ARM Assembly include file.
//
// This makes asmMain global and
// automatically converts it to _asmMain
// if this program is being assembled under macOS.
// It also converts printf to _printf for macOS.

            #include "aoaa.inc"

            .data

❶ title:      .asciz  "Listing 1-5"
saveLR:     .dword  0       // Save LR here.
hwStr:      .asciz  "Hello, world!\n"

            .text

// getTitle function, required by c.cpp, returns the
// name of this program. The title string must
// appear in the .text section:

 .align  2       // Code must be 4-byte aligned.

❷ getTitle:
            lea     x0, title
            ret

// Here's the main function called by the c.cpp function:

asmMain:

// LR is *highly* volatile and will be wiped
// out when this code calls the printf() function.
// We need to save LR in memory somewhere so we
// can return back to the OS using its value.
// For now, save it in the saveLR global
// variable:

            lea     x0, saveLR
            str     lr, [x0]

// Set up printf parameter (format string)
// and call printf():

          ❸ lea     x0, hwStr   // hwStr must be in .text.
            bl      printf      // Print the string.

// Back from printf(), restore LR with its original
// value so we can return to the OS:

          ❹ lea     x0, saveLR
            ldr     lr, [x0]

// Return to the OS:

            ret

标题字符串❶包含程序的标题(在本例中是“Listing 1-5”)。hwStr 变量包含主程序将传递给 printf()函数的“Hello, world!”字符串。getTitle()函数❷将标题字符串的地址返回给c.cpp程序。根据 ARM ABI,此函数将函数结果返回至 X0 寄存器。

进入 asmMain()函数(汇编语言主程序)时,代码必须保存 LR 寄存器的内容,因为调用 printf()将覆盖其值。这段代码将 LR 寄存器(它保存着返回到c.cpp主函数的地址)保存到.data 段中的 saveLR 全局变量❶。

注意

以这种方式保存 LR 寄存器的值并不是一种好做法。在第三章中,你将了解 ARM 堆栈,并发现一个更好的地方来保存 LR 寄存器中保存的返回地址。

实际打印“Hello, world!”的代码❸根据 ARM ABI 将 printf()格式字符串加载到 X0 寄存器中,然后使用 bl 指令调用 printf()。在返回到c.cpp之前,汇编代码必须使用保存在 saveLR 中的返回地址重新加载 LR 寄存器❹。

以下是构建并运行 Listing 1-5 中的程序的命令,以及程序的输出:

$ ./build Listing1-5
$ ./Listing1-5
Calling Listing1-5:
Hello, world!
Listing1-5 terminated

现在你已经拥有一个功能正常的汇编语言“Hello, world!”程序。

1.11 继续前进

本章为你提供了学习后续章节中新汇编语言特性的必要基础。你学习了 Gas 程序的基本语法、基本的 64 位 ARM 架构,以及如何使用aoaa.inc头文件使源文件在 macOS 和 Linux 之间可移植。你还学习了如何声明一些简单的全局变量,使用几条机器指令,以及如何将 Gas 程序与 C/C++代码组合,以便你可以调用 C 标准库中的例程(使用build脚本文件)。最后,你从命令行运行了该程序。

下一章将介绍数据表示,这是学习汇编语言的主要原因之一。

1.12 更多信息

第二章:2 数据表示与操作

许多初学者在学习汇编语言时遇到的一个主要障碍是二进制和十六进制计数系统的常见使用。然而,这些系统的优点远远超过它们的缺点:它们大大简化了其他主题的讨论,包括位操作、有符号数值表示、字符编码和打包数据。

本章讨论以下内容:

  • 二进制和十六进制计数系统

  • 二进制数据组织(位、半字节、字节、半字、字、双字)

  • 有符号和无符号计数系统

  • 二进制值的算术、逻辑、移位和旋转操作

  • 位域和打包数据

  • 浮动点和二进制编码十进制格式

  • 字符数据

本书的其余部分依赖于你对这些基本概念的理解。如果你已经在其他课程或学习中熟悉这些术语,你仍然应该浏览这些材料,确保没有遗漏任何内容,并学习本章介绍的指令,然后再进行下一章。如果你对这些内容不熟悉或只部分了解,请在继续之前仔细研究。不要跳过任何部分:本章中的所有内容都很重要

2.1 计数系统

大多数现代计算机系统并不使用十进制(基数为 10)系统来表示数值。相反,它们通常使用二进制计数系统。这是因为二进制(基数为 2)计数系统更接近用于表示计算机系统中数值的电子电路。

2.1.1 十进制

你已经使用十进制计数系统这么长时间了,以至于你可能已经理所当然地接受它。当你看到像 123 这样的数字时,你不会想着数字 123 的值;相反,你会在脑海中生成这个值代表多少个物品的形象。然而,实际上,数字 123 表示的是:

(1 × 10²) + (2 × 10¹) + (3 × 10⁰)

或者

100 + 20 + 3

在十进制位置计数系统中,十进制点左侧的每个数字表示一个从 0 到 9 的值,并乘以 10 的逐渐增大的幂。十进制点右侧的数字表示一个从 0 到 9 的值,并乘以 10 的逐渐减小的负幂。例如,值 123.456 表示的是:

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

或者

100 + 20 + 3 + 0.4 + 0.05 + 0.006

2.1.2 二进制

大多数现代计算机系统使用二进制逻辑进行操作。计算机使用两个电压水平(通常是 0 V 和 2.4 至 5 V)来表示数值。这两个电压水平可以表示正好两个唯一的值。这两个值可以是任何值,但通常代表二进制计数系统中的 0 和 1 这两个数字。

二进制计数系统与十进制计数系统类似,唯一不同的是,二进制只允许使用数字 0 和 1(而不是 0 到 9),并使用 2 的幂次方而不是 10 的幂次方。因此,将二进制数转换为十进制是容易的。对于二进制字符串中的每个 1,计算 2^n,其中 n 是该二进制数字的从零开始的位位置。例如,二进制值 11001010[2] 表示以下内容:

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

= 128[10] + 64[10] + 8[10] + 2[10]

= 202[10]

将十进制数转换为二进制稍微困难一些。你必须找到那些幂次的 2,所有这些幂次加在一起才能得到十进制结果。

将十进制数转换为二进制的一种简单方法是 偶/奇,除以 2 算法,包含以下步骤:

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

2.  将数字除以 2,并舍去任何小数部分或余数。

3.  如果商为 0,算法结束。

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

5.  返回到步骤 2,重复操作。

虽然二进制数字在高级语言(HLL)中几乎不重要,但它们在汇编语言程序中无处不在,因此确保你对它们感到熟悉。

从最纯粹的意义上讲,每个二进制数包含无限个数字(或比特,即二进制数字的简称)。例如,你可以用以下任意方式表示数字 5:

101

00000101

0000000000101

... 000000000000101

任何数量的前导零可以出现在二进制数字前面,而不会改变其值。由于 ARM 通常处理 8 比特一组的数字,本书将所有二进制数扩展为 4 位或 8 位的倍数。按照这一约定,你会将数字 5 表示为 0101[2] 或 00000101[2]。

为了让更大的数字更易读,我通常会用下划线分隔每组 4 个二进制比特。例如,我会将二进制值 1010111110110010 写作 1010_1111_1011_0010。 (Gas 实际上不允许在二进制数中间插入下划线;我这样做只是为了便于阅读。)

通常的约定是按以下方式编号每个比特:二进制数中最右边的比特是比特位置 0,向左的每个比特依次编号。一个 8 位的二进制值使用比特位置 0 到 7:

X[7] X[6] X[5] X[4] X[3] X[2] X[1] X[0]

一个 16 位的二进制值使用比特位置 0 到 15:

X[15] X[14] X[13] X[12] X[11] X[10] X[9] X[8] X[7] X[6] X[5] X[4] X[3] X[2] X[1] X[0]

一个 32 位的二进制值使用比特位置 0 到 31,以此类推。

比特 0 是低位(LO)比特;有些人称其为最不重要的比特。最左边的比特称为高位(HO)比特,或最重要的比特。我会用它们各自的比特编号来引用中间的比特。

在 Gas 中,你可以通过以 0b 序列开头的一串 0 和 1 来指定二进制值——例如,0b10111111。

2.1.3 十六进制

不幸的是,二进制数字冗长:表示值 202[10]需要八个二进制数字,而只需三个十进制数字。处理大数值时,二进制数字迅速变得笨重。然而,由于计算机“以二进制思考”,在为计算机创建值时使用二进制计数系统是方便的。尽管你可以在十进制(人类最为习惯的表示方式)和二进制之间进行转换,但转换并非一项简单任务。此外,许多汇编语言常量在以二进制(而不是十进制)表示时更易于阅读和理解,因此通常使用二进制更为合适。

十六进制(基数为 16)计数系统解决了二进制系统固有的许多问题:十六进制数字简洁,而且转换为二进制很简单,反之亦然。因此,大多数工程师使用十六进制计数系统而不是二进制。

由于十六进制数字的基数(基数)为 16,每个十六进制数字在十六进制点左侧表示某个值,乘以 16 的连续幂。例如,数字 1,234[16]等于:

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

或者

4,096[10] + 512[10] + 48[10] + 4[10] = 4,660[10]

每个十六进制数字可以表示从 0 到 15[10]的 16 个值。由于十进制只有 10 个数字,因此你需要额外的 6 个数字来表示 10[10]到 15[10]之间的值。为了避免为这些数字创建新的符号,约定使用字母 A 到 F。以下是有效十六进制数字的示例:

1234[16]

DEAD[16]

BEEF[16]

0AFB[16]

F001[16]

D8B4[16]

由于你经常需要将十六进制数字输入到计算机系统中,而在大多数计算机系统中你不能输入下标来表示相关值的基数,因此你需要使用不同的机制来表示十六进制数字。在本书中,我使用以下 Gas 约定:

  • 所有十六进制值都有 0x 前缀(例如,0x123A4 和 0xDEAD)。

  • 所有二进制值都以 0b 序列开头(例如,0b10010)。

  • 十进制数字没有前缀字符。

  • 如果上下文已经能明确基数,我可能会省略 0x 或 0b 前缀字符。

Gas 还允许使用以前导 0 开头且只包含 0 到 7 的数字的八进制(基数为 8)数字。然而,本书不使用八进制数字。

下面是使用 Gas 符号表示的有效十六进制数字示例:

0x1234

0xDEAD

0xBEEF

0xAFB

0xF001

0xD8B4

正如你所看到的,十六进制数字简洁且易于阅读。此外,你可以轻松地在十六进制和二进制之间进行转换。表 2-1 提供了将任何十六进制数字转换为二进制数字,或将其反向转换所需的所有信息。

要将十六进制数转换为二进制数,将每个十六进制数字替换为相应的 4 位二进制值。例如,要将 0xABCD 转换为二进制值,可以按照 表 2-1 将每个十六进制数字转换:A 变成 1010,B 变成 1011,C 变成 1100,D 变成 1101,得到二进制值 1010_1011_1100_1101。

表 2-1:二进制/十六进制转换

二进制 十六进制 十进制
0000 0 0
0001 1 1
0010 2 2
0011 3 3
0100 4 4
0101 5 5
0110 6 6
0111 7 7
1000 8 8
1001 9 9
1010 A 10
1011 B 11
1100 C 12
1101 D 13
1110 E 14
1111 F 15

将二进制数转换为十六进制格式几乎同样简单:

1.  用 0 填充二进制数,确保数字包含 4 位的倍数。例如,给定二进制数 1011001010,在数字的左侧加上 2 位,使其包含 12 位:001011001010。

2.  将二进制值分成 4 位一组。在这个例子中,你将得到 0010_1100_1010。

3.  查找这些二进制值在 表 2-1 中的对应十六进制数字,并替换成适当的十六进制数字:0x2CA。

将其与十进制和二进制之间,或者十进制和十六进制之间的转换难度进行对比!

因为你需要反复进行十六进制与二进制之间的转换,所以花几分钟记住转换表是值得的。即使你有一个可以为你进行转换的计算器,一旦熟练掌握,手动转换会更加快捷和方便。

2.2 数字与表示

很多人将数字与其表示混淆。初学汇编语言的学生常常问:“我在 W0 寄存器中有一个二进制数;我如何将它转换为 W0 寄存器中的十六进制数?”答案是,“你不需要转换。”尽管可以强烈争辩说内存或寄存器中的数字是以二进制表示的,但最好将内存或寄存器中的值视为抽象的数字量。像 128、0x80 或 0b10000000 这样的符号串并不是不同的数字;它们只是代表人们所说的“128”这一数量的不同表示。在计算机内部,数字就是数字,无论其表示形式如何;只有在你以人类可读的形式输入或输出值时,表示形式才重要。

纯汇编语言没有通用的打印或写入函数,可以调用它们将数字量以字符串的形式显示在控制台上。第九章演示了如何编写自己的过程来处理这个过程。目前,本书中的 Gas 代码依赖于 C 标准库的 printf() 函数来显示数字值。考虑一下列表 2-1,它将各种十进制值转换为其十六进制等价值。

// Listing2-1.S
//
// Displays some numeric values on the console
#include "aoaa.inc"

            .data
// Program title, required by C++ code:

titleStr:   .asciz      "Listing 2-1"

// Format strings for three calls to printf():

fmtStrI:    .asciz      "i=%d, converted to hex=%x\n"
fmtStrJ:    .asciz      "j=%d, converted to hex=%x\n"
fmtStrK:    .asciz      "k=%d, converted to hex=%x\n"

// Some values to print in decimal and hexadecimal form:

            .align      2  // Be nice and word-align.
i:          .dword      1
j:          .dword      123
k:          .dword      456789
saveLR:     .dword      0

            .text
            .align      2      // Code must be word-aligned.
            .extern     printf // printf is outside this code.

// Return program title to C++ program:

getTitle:

// Load address of "titleStr" into the X0 register (X0 holds
// the function return result) and return back to the caller:

            lea x0, titleStr
            ret

// Here is the asmMain function:

            .global     asmMain
asmMain:
            sub         sp, sp, #64 // Magic instruction

// Save LR so we can return to C++ program:

            lea         x0, saveLR
            str         lr, [x0]

// Call printf three times to print the three values
// i, j, and k:
//
// printf("i=%d, converted to hex=%x\n", i, i);

          ❶ lea         x0, fmtStrI
            vparm2      i           // Get parameter 2
            vparm3      i           // Get parameter 3
            bl          printf

// printf("j=%d, converted to hex=%x\n", j, j);

          ❷ lea         x0, fmtStrJ
            vparm2      j
            vparm3      j
            bl          printf

// printf("k=%d, converted to hex=%x\n", k, k);

          ❸ lea         x0, fmtStrK
            vparm2      k
            vparm3      k
            bl          printf

// Restore LR so we can return to C++ program:

            lea         x0, saveLR
            ldr         lr, [x0]

            add         sp, sp, #64  // Magic instruction
            ret                      // Returns to caller

要模拟 C 语句

printf("i=%d, converted to hex=%x\n", i, i);

代码必须将三个参数❶加载到 X0、X1 和 X2 寄存器中:格式化字符串的地址(fmtStrI)和当前存储在变量 i 中的值(分别在 X1 和 X2 中传递两次)。请注意,vparm2 和 vparm3 宏会分别将它们的参数(i)加载到 X1 和 X2 寄存器中。同样,代码设置 X0、X1 和 X2 以打印存储在 j 和 k 变量中的值❷❸。

这个十进制到十六进制的转换程序使用了第一章中的通用c.cpp程序以及通用的build shell 脚本。你可以通过在命令行中使用以下命令来编译并运行该程序:

$ ./build Listing2-1
$ ./Listing2-1
Calling Listing2-1:
i=1, converted to hex=1
j=123, converted to hex=7b
k=456789, converted to hex=6f855
Listing2-1 terminated

正如你所看到的,这个程序以十进制和十六进制形式显示了 i、j 和 k 的初始化值。

2.3 数据组织

在纯数学中,一个值的表示可能需要任意数量的比特。另一方面,计算机通常使用固定数量的比特。常见的集合包括单个比特、4 个比特的组合(称为半字节)、8 个比特(字节)、16 个比特(半字hword)、32 个比特()、64 个比特(双字dword)、128 个比特(四字qword)等。以下小节将描述 ARM CPU 如何组织这些比特组以及你可以使用它们表示的典型值。

2.3.1 比特

二进制计算机上的最小数据单元是一个比特。通过一个比特,你可以表示两个不同的项目,如 0 或 1、真或假、对或错。然而,你并不是仅限于表示二进制数据类型;你可以使用一个比特来表示 723 和 1,245,或者可能是红色和蓝色,甚至是红色和数字 3,256。你可以用一个比特表示任何两个值,但只能用一个比特表示两个值。

不同的比特可以表示不同的东西。例如,你可以用一个比特表示值 0 和 1,而另一个比特可以表示真和假,另一个比特可以表示红色和蓝色。然而,你不能仅仅通过看一个比特来知道它表示什么。

这说明了计算机数据结构背后的整个理念:数据是你定义的内容。如果你用一个比特表示布尔值(真/假),那么根据你的定义,这个比特就代表真或假。然而,你必须保持一致。如果你在程序的某个地方使用一个比特表示真或假,你就不应当在后续用这个比特表示红色或蓝色。

2.3.2 半字节

半字节是由 4 个比特组成的集合。通过半字节,你可以使用这 4 个比特的 16 种可能的独特组合来表示最多 16 个不同的值:

0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111

半字节需要 4 位来表示 二进制编码十进制(BCD) 数字和十六进制数字中的一个数字。对于十六进制数字,每个值 0、1、2、3、4、5、6、7、8、9、A、B、C、D、E 和 F 都用 4 位表示。BCD 使用 4 位二进制位来表示十进制数字中的每个数字(0、1、2、3、4、5、6、7、8、9)。

BCD 需要 4 位,因为 3 位只能表示 8 个不同的值,而表示 10 个值至少需要 4 位。(用 4 位表示的额外 6 个值在 BCD 表示法中从未使用。)实际上,任何 16 个不同的值都可以用半字节表示,尽管十六进制和 BCD 数字是你会用单个半字节表示的主要项。

2.3.3 字节

毫无疑问,ARM 微处理器使用的最重要的数据结构是 字节,它由 8 位组成。ARM 的主存和 I/O 地址都是字节地址。这意味着,ARM 程序可以单独访问的最小项是 8 位值。要访问更小的数据项,必须先读取包含数据的字节,然后去除不需要的位。字节中的位通常从 0 到 7 编号,如 图 2-1 所示。

图 2-1:位编号

位 0 是 LO 位最低有效位,位 7 是 HO 位最高有效位,我将通过位号来指代其他任何位。

一个字节恰好包含 2 个半字节,如 图 2-2 所示。

图 2-2:字节中的 2 个半字节

位 0 到 3 组成 LO 半字节,位 4 到 7 组成 HO 半字节。因为一个字节恰好包含 2 个半字节,所以字节值需要两个十六进制数字来表示。

由于一个字节包含 8 位,它可以表示 2⁸(256)个值。通常,汇编程序员使用一个字节来表示范围为 0 到 255 的数字值,范围为 -128 到 +127 的有符号数(参见第 2.6 节,“有符号与无符号数字”,在 第 65 页),字符编码,以及其他不超过 256 个值的特殊数据类型。许多数据类型的项数少于 256,因此 8 位通常足够。

因为 ARM 是一个字节寻址的机器,所以操作一个完整字节比操作单独的比特或半字节更高效。这意味着,即使使用不到 8 位的空间也能表示 2 到 256 项数据类型,使用完整字节来表示这些数据类型会更高效。

字节的最重要用途之一是存储字符值。键盘上输入的字符、屏幕上显示的字符以及打印机上打印的字符都有数值。为了与外界通信,个人计算机通常使用美国标准信息交换码(ASCII)字符集的变种或 Unicode 字符集。ASCII 字符集有 128 个定义的代码。(因为 Unicode 字符集有远多于 256 个字符,一个字节不足以表示所有 Unicode 字符;有关详细信息,请参见第 2.17 节,“Gas 对 Unicode 字符集的支持”,第 102 页。)

字节也是你在 Gas 程序中可以创建的最小变量。要创建一个任意字节变量,请使用.byte 数据类型,如下所示:

 .data
byteVar:  .byte   0

字节数据类型可保存任何 8 位值:小的带符号整数、小的无符号整数、字符等。你需要自己跟踪放入字节变量中的对象类型。

2.3.4 半字

半字是 16 位的组合。半字中的位从 0 到 15 编号,如图 2-3 所示。与字节一样,位 0 是 LO 位。对于半字,位 15 是 HO 位。当引用半字中的其他任何位时,我将使用其位位置编号。

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

半字包含正好 2 个字节,如图 2-4 所示。位 0 到 7 组成 LO 字节,位 8 到 15 组成 HO 字节。

图 2-4:半字中的 2 个字节

半字还包含 4 个四分之一字,如图 2-5 所示。

图 2-5:半字中的四分之一字

使用 16 位,你可以表示 2¹⁶(65,536)个值。这些值可以是 0 到 65,535 范围内的值,或者通常的带符号值–32,768 到+32,767,或任何其他没有超过 65,536 个值的数据类型。

半字的两个主要用途是存储短整型带符号数值和短整型无符号数值。无符号数值由与半字中的位相对应的二进制值表示。带符号数值使用二进制补码形式表示数值(参见第 2.6 节,“带符号数值与无符号数值”,第 65 页。)

与字节一样,你也可以在 Gas 程序中创建半字变量。要创建一个任意的半字变量,只需使用.hword 数据类型,如下所示:

 .data
hw:      .hword  0

这定义了一个 16 位变量(hw),初始值为 0。

2.3.5 字

一个字的数量是 32 位,如图 2-6 所示。

图 2-6:字中的位编号

自然地,这个字可以被分为 HO 半字和 LO 半字,4 个字节,或 8 个四分之一字,如图 2-7 所示。

图 2-7:字中的半字、字节和四分之一字

单词可以表示各种事物。你通常用它们来表示 32 位整数值(允许无符号数字的范围为 0 到 4,294,967,295,或有符号数字的范围为 -2,147,483,648 到 +2,147,483,647);32 位浮点值也可以适配为一个单词。

你可以通过使用 .word 声明来创建一个任意的单词变量,如下所示:

 .data
w:    .word   0

这定义了一个初始化为 0 的 32 位变量(w)。

2.3.6 双字和四字

双字(64 位)值也很重要,因为 64 位整数、指针和某些浮点数据类型需要 64 位。类似地,四字(128 位)值也很重要,因为 ARM Neon 指令集可以操作 128 位值。aoaa.inc 包含文件包括 .dword.qword 宏,这使得 Gas 可以通过使用 dword 和 qword 类型来声明 64 位和 128 位值:

 .data
dw:   .dword 0
qw:   .qword 0

如果没有 aoaa.inc 文件,标准的 Gas 指令是 .quad(用于双字)和 .octa(用于四字)。本书使用 .dword.qword,因为它们更具描述性。

注意

从技术上讲,Gas 确实支持 .dword 。是 macOS 汇编器(Clang 汇编器)不支持 .dword ,并且需要在 aoaa.inc 头文件中使用宏。

你不能直接使用标准指令(如 mov、add 和 sub)操作 128 位整数对象,因为标准的 ARM 整数寄存器一次只能处理 64 位。在第八章中,你将看到如何操作这些 扩展精度 值;第十一章描述了如何通过使用 SIMD 指令直接操作 qword 值。

2.4 位的逻辑运算

尽管你可以使用字节、半字、单词等表示数值,但这些也是可以在位级别操作的位的组。此部分描述了单个位的操作以及如何在较大的数据结构中操作这些位。你通常会对十六进制和二进制数字进行四种逻辑操作(布尔函数):与、或、异或(排他或)和非。

2.4.1 与

与运算是 二元 的,这意味着它接受恰好两个单独的二进制位操作数,如下所示:

0 and 0 = 0
0 and 1 = 0
1 and 0 = 0
1 and 1 = 1

许多文献将与操作称为 二元操作。术语 二元(dyadic)与之含义相同,并避免与二进制计数系统的混淆。

一个 真值表,其形式如表 2-2 所示,是表示与运算的一种紧凑方式。

表 2-2:与运算真值表

0 1
0 0 0
1 0 1

真值表的工作方式就像你在学校里遇到的乘法表一样。左列中的值对应与运算的左操作数。第一行中的值对应与运算的右操作数。行和列交点处的值(对于一对特定的输入值)是将这两个值进行与运算后的结果。

用英语描述,AND 操作是:“如果第一个操作数是 1 且第二个操作数是 1,结果是 1;否则,结果是 0。” 你也可以这么表述:“如果任一操作数是 0,结果是 0。”

你可以使用 AND 操作强制得到 0 结果:如果一个操作数是 0,结果始终是 0,无论另一个操作数是什么。例如,在表 2-2 中,标记为 0 输入的行仅包含 0,标记为 0 的列也仅包含 0。相反,如果一个操作数包含 1,结果正好是第二个操作数的值。AND 操作的这些结果很重要,特别是当你需要强制将位设置为 0 时。本章将探讨 AND 操作的这些应用,详见第 2.5 节“二进制数和位串的逻辑操作”,请参阅下一页。

2.4.2 OR

OR 操作,也是二元操作,其定义如下:

0 or 0 = 0
0 or 1 = 1
1 or 0 = 1
1 or 1 = 1

表 2-3 展示了 OR 操作的真值表。

表 2-3:OR 真值表

OR 0 1
0 0 1
1 1 1

口语中,OR 操作是:“如果第一个操作数或第二个操作数(或两者)为 1,结果是 1;否则,结果是 0。” 这也称为包含或操作。

如果 OR 操作的一个操作数是 1,结果始终为 1,不论第二个操作数的值如何。如果一个操作数是 0,结果总是第二个操作数的值。与 AND 操作一样,这是 OR 操作的一个重要副作用,证明在实际应用中非常有用。

包含或操作与标准英语意义之间存在差异。考虑句子:“我要去商店,或者我去公园。” 这样的说法意味着说话者要么去商店,要么去公园,而不会去两个地方。这种口语中使用的或者异或操作更为类似,而不是包含或操作。

2.4.3 XOR

XOR(异或)操作也是二元操作。其定义如下:

0 xor 0 = 0
0 xor 1 = 1
1 xor 0 = 1
1 xor 1 = 0

表 2-4 展示了 XOR 操作的真值表。

表 2-4:XOR 真值表

XOR 0 1
0 0 1
1 1 0

用英语描述,XOR 操作是:“如果第一个操作数或第二个操作数为 1,但不是两者都为 1,结果是 1;否则,结果是 0。”

如果异或操作的一个操作数是 1,结果总是另一个操作数的反转值;也就是说,如果一个操作数是 1,且另一个操作数是 1,结果是 0;如果另一个操作数是 0,结果是 1。如果第一个操作数是 0,结果正好是第二个操作数的值。这个特性让你可以选择性地反转位串中的位。

2.4.4 NOT

NOT 操作是一元的,意味着它只接受一个操作数:

not 0 = 1
not 1 = 0

表 2-5 展示了 NOT 操作的真值表。

表 2-5:NOT 真值表

NOT 0 1
1 0

NOT 运算符会反转输入位的值。

2.5 二进制数和比特串上的逻辑运算

上一节定义了单比特操作数的逻辑函数。由于 ARM 使用 8、16、32、64 或更多位的分组,本节将这些函数的定义扩展到处理超过 2 个比特的情况。

ARM 上的逻辑函数是基于逐位(或按位)的。给定两个值,这些函数首先对每个值的第 0 位进行操作,生成结果的第 0 位;然后对输入值的第 1 位进行操作,生成结果的第 1 位,依此类推。例如,如果你想计算以下两个 8 位数字的与运算,你需要对每一列独立地进行与运算:

0b1011_0101
0b1110_1110
-----------
0b1010_0100

你可以将这种逐位计算应用于其他逻辑函数。要对两个十六进制数字执行逻辑运算,首先将它们转换为二进制。

使用 AND 或 OR 操作将位强制为 0 或 1,以及使用 XOR 操作反转位,在处理比特串(例如二进制数)时非常重要。这些操作可以让你选择性地操作比特串中的某些位,同时不影响其他位。

例如,如果你有一个 8 位二进制值X,并且想确保第 4 到第 7 位为 0,你可以将值X与二进制值 0000_1111 进行与运算。这个逐位与运算将强制第 4 位的高 4 位为 0,并保持X的低 4 位不变。同样,你可以通过将X与 0000_0001 进行或运算,然后与 0000_0100 进行异或运算,将X的低位强制为 1,并反转X的第 2 位。

以这种方式使用 AND、OR 和 XOR 操作来操作比特串被称为屏蔽比特串,因为你可以使用特定的值(AND 为 1,OR/XOR 为 0)来屏蔽或遮掩操作中强制为 0、1 或其反值的特定位。屏蔽这一术语源于涂料。画家使用胶带(遮蔽胶带)和纸张来遮掩(屏蔽)他们在绘画时想要保护的物体部分。类似地,程序员使用 1(通过 AND 操作)来保护他们希望保留为 0 的比特位置,而使用 0(通过 OR 操作)来保护他们希望强制为 1 的比特位置。

ARM-64 CPU 支持五条指令,将这些逐位逻辑运算应用于其操作数:and、ands、orr、eor 和 mvn。and、ands、orr 和 eor 指令使用与第一章中学到的 add 和 sub 指令相同的语法:

and   `dest`, `source`left, `source`right
ands  `dest`, `source`left, `source`right  // Affects the flags
orr   `dest`, `source`left, `source`right
eor   `dest`, `source`left, `source`right  // XOR operation

这些操作数有与加法操作数相同的限制。具体来说,左侧源操作数必须是寄存器操作数,右侧源操作数必须是寄存器或常量,目标操作数必须是寄存器。操作数还必须具有相同的大小。你将在第二章的 2.19 节“Operand2”中看到此语法的扩展。

orr 和 eor 指令没有带有“s”后缀的版本。如果你希望在这些指令执行后测试标志,你将不得不绕过这个指令集中的奇怪限制。

立即数(sourceright 操作数)与加法(add)和减法(sub)的立即数有完全不同的限制集。有关什么构成合法的立即数,请参阅第 2.19 节“操作数 2”,在第 106 页。

这些指令通过以下公式计算出明显的按位逻辑运算:

`dest` = `source`left `operator` `source`right

ARM 并没有实际的非(not)指令。相反,一个 mov 指令的变种执行这一功能:mvn(移位并取反)。该指令的格式如下:

mvn  `dest`, `source`

请注意,这条指令没有提供带有“s”后缀的形式,因此执行后不会更新条件码标志。

该指令计算出以下结果:

`dest` = not(`source`)

操作数必须都是寄存器。

清单 2-2 中的程序从用户输入两个十六进制值,并计算它们的逻辑与(AND)、或(OR)、异或(XOR)和非(NOT)运算。

// Listing2-2.S
//
// Demonstrate AND, OR, XOR, and NOT operations.

#include "aoaa.inc"

             .data
leftOp:      .dword     0xf0f0f0f
rightOp1:    .dword     0xf0f0f0f0
rightOp2:    .dword     0x12345678
result:      .dword     0
saveLR:      .dword     0

titleStr:    .asciz   "Listing 2-2"

fmtStr1:     .asciz   "%lx AND %lx = %lx\n"
fmtStr2:     .asciz   "%lx OR  %lx = %lx\n"
fmtStr3:     .asciz   "%lx XOR %lx = %lx\n"
fmtStr4:     .asciz   "NOT %lx = %lx\n"

             .text
             .align     2   // Make code word-aligned.

             .extern    printf

// Return program title to C++ program:

 .global     getTitle
getTitle:

// Load address of "titleStr" into the X0 register (X0 holds the
// function return result) and return back to the caller:

            lea     x0, titleStr
            ret

// Here is the "asmMain" function.

            .global asmMain
asmMain:

// "Magic" instruction offered without explanation at this point:

            sub     sp, sp, 64

// Save LR so we can return to C++ code:

            lea     x0, saveLR
            str     lr, [x0]

// Demonstrate the AND operation:

          ❶ lea     x0, leftOp
            ldr     x1, [x0]
            lea     x0, rightOp1
            ldr     x2, [x0]
            and     x3, x1, x2  // Compute left AND right.
            lea     x0, result
            str     x3, [x0]

            lea     x0, fmtStr1 // Print result.
            vparm2  leftOp
            vparm3  rightOp1
            vparm4  result
            bl      printf

// Demonstrate the OR operation:

          ❷ lea     x0, leftOp
            ldr     x1, [x0]
            lea     x0, rightOp1
            ldr     x2, [x0]
            orr     x3, x1, x2  // Compute left OR right.
            lea     x0, result
            str     x3, [x0]

            lea     x0, fmtStr2 // Print result.
            vparm2  leftOp
            vparm3  rightOp1
            vparm4  result
            bl      printf

// Demonstrate the XOR operation:

          ❸ lea     x0, leftOp
            ldr     x1, [x0]
            lea     x0, rightOp1
            ldr     x2, [x0]
            eor     x3, x1, x2  // Compute left XOR right.
            lea     x0, result
            str     x3, [x0]

            lea     x0, fmtStr3 // Print result.
            vparm2  leftOp
            vparm3  rightOp1
            vparm4  result
            bl      printf

// Demonstrate the NOT instruction:

          ❹ lea     x0, leftOp
            ldr     x1, [x0]
            mvn     w1, w1      // W1 = not W1 (32 bits)
            lea     x0, result
            str     x1, [x0]

            lea     x0, fmtStr4 // Print result.
            vparm2  leftOp
            vparm3  result
            bl      printf

          ❺ lea     x0, rightOp1
            ldr     x1, [x0]
            mvn     w1, w1      // W1 = not W1 (32 bits)
            lea     x0, result
            str     x1, [x0]

            lea     x0, fmtStr4 // Print result.
            vparm2  rightOp1
            vparm3  result
            bl      printf

          ❻ lea     x0, rightOp2
            ldr     x1, [x0]
            mvn     w1, w1      // W1 = not W1
            lea     x0, result
            str     x1, [x0]

            lea     x0, fmtStr4 // Print result.
            vparm2  rightOp2
            vparm3  result
            bl      printf

// Another "magic" instruction that undoes the effect of
// the previous one before this procedure returns to its
// caller:

 add     sp, sp, #64

// Restore LR so we can return to C++ code:

            lea     x0, saveLR
            ldr     lr, [x0]
            ret     // Returns to caller

该代码计算 leftOp 和 rightOp1 的逻辑与 ❶、或 ❷ 和异或 ❸。然后输出结果。接着,代码计算 leftOp ❹、rightOp1 ❺ 和 rightOp2 ❻ 的非(NOT)值,并打印它们的结果。

这是清单 2-2 中程序的构建命令和输出:

$ ./build Listing2-2
$ ./Listing2-2
Calling Listing2-2:
f0f0f0f AND f0f0f0f0 = 0
f0f0f0f OR  f0f0f0f0 = ffffffff
f0f0f0f XOR f0f0f0f0 = ffffffff
NOT f0f0f0f = f0f0f0f0
NOT f0f0f0f0 = f0f0f0f
NOT 12345678 = edcba987
Listing2-2 terminated

如你所见,AND 操作清除位,OR 操作设置位,而 XOR 和 NOT 操作反转位。

2.6 有符号和无符号数

到目前为止,本章将二进制数视为无符号值。二进制数 0 ... 00000 代表 0,0 ... 00001 代表 1,0 ... 00010 代表 2,以此类推,直到无穷大。使用 n 位,你可以表示 2^n 个无符号数字。

那负数呢?如果将可能的组合一半分配给负值,另一半分配给正值和零,那么使用 n 位,你可以表示有符号值的范围从 –2n*(–1) 到 +2n*(–1) – 1。这意味着你可以使用一个 8 位字节表示负值 –128 到 –1 和非负值 0 到 127。使用 16 位半字,你可以表示的范围是 –32,768 到 +32,767。使用 32 位字,你可以表示的范围是 –2,147,483,648 到 +2,147,483,647。

在数学和计算机科学中,补码法 将负数和非负数(包括正数和零)编码为两个相等的集合,以便它们可以使用相同的算法或硬件执行加法,并根据符号产生正确的结果。

ARM 微处理器使用 二进制补码 表示有符号整数。在这种系统中,数字的最高有效位(HO 位)是 符号位:整数被分为两个相等的集合。如果符号位为 0,数字为正(或零);如果符号位为 1,数字为负(采用补码形式,我将在后面描述)。

以下是一些 16 位正数和负数的例子:

0x8000 是负数,因为高位(HO 位)是 1。

0x100 是正数,因为 HO 位是 0。

0x7FFF 是正数。

0xFFFF 是负数。

0xFFF 是正数。

如果 HO 位是 0,则该数字为正数(或零),并使用标准的二进制格式。如果 HO 位是 1,则该数字为负数,采用二补数形式:这种魔法形式支持负数和非负数的加法,无需特殊硬件。

你可以通过以下算法步骤将正数转换为其负的二补数形式:

1.  对数字中的所有位进行取反;即应用 NOT 函数。

2.  对取反后的结果加 1,并忽略 HO 位的进位。

这会生成一个符合补数形式数学定义的位模式。特别地,使用这种形式加法计算负数和非负数时,会产生预期的结果。

例如,要计算 –5 的 8 位等效值:

1.  将 5 写成二进制:0000_0101。

2.  对所有位进行取反:1111_1010。

3.  加 1 以获得结果:1111_1011。

如果对 –5 进行二补数运算,你会得到原始值 0000_0101:

1.  对 –5 取二补数:1111_1011。

2.  对所有位进行取反:0000_0100。

3.  加 1 以获得结果 0000_0101。

如果你将 +5 和 –5 相加(忽略 HO 位的进位),你将得到预期的结果 0:

 0b1111_1011     Take the two's complement for -5.
   + 0b0000_0101     Invert all the bits and add 1.
     -----------
(1)  0b0000_0000     Sum is zero, if you ignore carry.

以下示例提供了一些正负 16 位带符号值:

0x7FFF: +32,767,最大 16 位正数

0x4000: +16,384

0x8000: –32,768,最小 16 位负数

要将前面的数字转换为其负值(即取反),请执行以下操作:

0x7FFF: 0b0111_1111_1111_1111   +32,767
        0b1000_0000_0000_0000   Invert all the bits (8000h).
        0b1000_0000_0000_0001   Add 1 (8001h or -32,767).
x04000: 0b0100_0000_0000_0000   16,384
        0b1011_1111_1111_1111   Invert all the bits (0BFFFh).
        0b1100_0000_0000_0000   Add 1 (0C000h or -16,384).
0x8000: 0b1000_0000_0000_0000   -32,768
        0b0111_1111_1111_1111   Invert all the bits (7FFFh).
        0b1000_0000_0000_0000   Add 1 (8000h or -32,768).

0x8000 取反后变为 0x7FFF。加 1 后,得到 0x8000!等等,这怎么回事?–(–32,768) 还是 –32,768 吗?当然不是。但是,值 +32,768 不能用 16 位带符号数表示,因此无法对最小负值取反。

通常,你不需要手动执行二补数操作。ARM 微处理器提供了一条指令 neg(取反),可以为你执行这个操作:

neg  `dest`, `source`
negs `dest`, `source`  // Sets condition code flags

该指令计算 dest = -source,操作数必须是寄存器。由于这是一个带符号整数操作,因此只有在带符号整数值上进行操作才有意义。列表 2-3 演示了带符号 32 位整数值的二补数运算和 neg 指令。

// Listing2-3.S
//
// Demonstrates two's complement operation and input of
// numeric values

#include "aoaa.inc"

            .equ        maxLen, 256

            .data
titleStr:   .asciz      "Listing 2-3"

prompt1:    .asciz      "Enter an integer between 0 and 127:"
fmtStr1:    .asciz      "Value in hexadecimal: %x\n"
fmtStr2:    .asciz      "Invert all the bits (hexadecimal): %x\n"
fmtStr3:    .asciz      "Add 1 (hexadecimal): %x\n"
fmtStr4:    .asciz      "Output as signed integer: %d\n"
fmtStr5:    .ascii      "Negate again and output as signed integer:"
            .asciz      " %d\n"

fmtStr6:    .asciz      "Using neg instruction: %d\n"

intValue:   .dword      0
saveLR:     .dword      0

// The following reserves 256 bytes of storage to hold a string
// read from the user.

❶ input:      .space      maxLen, 0

            .text
            .align      2
            .extern     printf
            .extern     atoi
          ❷ .extern     readLine

// Return program title to C++ program:

            .global     getTitle
getTitle:
            lea         x0, titleStr
            ret

// Here is the asmMain function:

            .global     asmMain
asmMain:

// "Magic" instruction offered without explanation at this point:

            sub     sp, sp, #128

// Save LR so we can return to C++ program:

            lea     x0, saveLR
            str     lr, [x0]

// Read an unsigned integer from the user: this code will blindly
// assume that the user's input was correct. The atoi function
// returns zero if there was some sort of error on the user
// input. Later chapters in AoAA will describe how to check for
// errors from the user.

            lea     x0, prompt1
            bl      printf

            lea     x0, input
            mov     x1, #maxLen
            bl      readLine

// Call C stdlib strtol function:
//
// i = strtol(str, NULL, 10)

 ❸ lea     x0, input
            mov     x1, xzr
            mov     x2, #10
            bl      strtol
            lea     x1, intValue
            str     x0, [x1]

// Print the input value (in decimal) as a hexadecimal number:

            lea     x0, fmtStr1
            vparm2  intValue
            bl      printf

// Perform the two's complement operation on the input number.
// Begin by inverting all the bits:

            lea     x1, intValue
            ldr     x0, [x1]
            mvn     x0, x0      // Not X0
            str     x0, [x1]    // Store back into intValue.
            lea     x0, fmtStr2
            vparm2  intValue
            bl      printf

// Invert all the bits and add 1 (inverted value is in intValue):

            lea     x0, intValue
            ldr     x1, [x0]
            add     x1, x1, #1
            str     x1, [x0]    // Store back into intValue.
            lea     x0, fmtStr3
            vparm2  intValue
            bl      printf

            lea     x0, fmtStr4 // Output as integer rather
            vparm2  intValue    // than hexadecimal.
            bl      printf

// Negate the value and print as a signed integer. Note that
// intValue already contains the negated value, so this code
// will print the original value:

            lea     x0, intValue
            ldr     x1, [x0]
            mvn     x1, x1
            add     x1, x1, #1
            str     x1, [x0]
            lea     x0, fmtStr5
            vparm2  intValue
            bl      printf

// Negate the value using the neg instruction:

            lea     x0, intValue
            ldr     x1, [x0]
 neg     x1, x1
            str     x1, [x0]
            lea     x0, fmtStr6
            vparm2  intValue
            bl      printf

// Another "magic" instruction that undoes the effect of the
// previous one before this procedure returns to its caller:

            lea     x0, saveLR
            ldr     lr, [x0]
            add     sp, sp, #128
            ret     // Returns to caller

.space 指令❶在本章中是新的。这条指令保留一个缓冲区(字节数组)。第一个操作数指定要保留的字节数,第二个操作数指定为缓冲区中的每个字节赋值。此特定指令为用户输入的文本行预留了 256 字节。我们将在第四章中进一步讨论数组和数组的内存分配。

readLine函数❷由c.cpp源文件中的 C++代码提供。该函数需要两个参数:X0 寄存器中的缓冲区地址和 X1 寄存器中的最大输入计数(包括零终止字节的空间)。当调用此函数时,它将从标准输入设备读取一行文本,并将这些字符放入指定的缓冲区(零终止,如果输入大于 X1 中传递的值,则进行截断)。

strtol函数❸是一个 C 标准库函数,它将一个包含数字字符的字符串转换为长整型值(64 位)。此函数需要三个参数:X0 包含一个缓冲区的地址(该缓冲区包含待转换的字符串);X1 指向数字字符串的末尾,如果包含 NULL(0),则被忽略;X2 包含用于转换的基数(进制)。该函数将转换后的值返回至 X0 寄存器。

这是第 2-3 节的构建命令和程序输出(在此次运行程序时,我提供了 123 作为输入):

$ ./build Listing2-3
$ ./Listing2-3
Calling Listing2-3:
Enter an integer between 0 and 127:123
Value in hexadecimal: 7b
Invert all the bits (hexadecimal): ffffff84
Add 1 (hexadecimal): ffffff85
Output as signed integer: -123
Negate again and output as signed integer: 123
Using neg instruction: -123
Listing2-3 terminated

如你所见,程序会读取一个用户输入的十进制整数值,反转其位,增加 1(即二进制补码操作),然后显示结果。

2.7 符号扩展和零扩展

将一个小的二进制补码值转换为更多位数的值,可以通过符号扩展操作来完成。

要将带符号的值从一定的位数扩展到更多的位数,需要将符号位复制到新格式中所有的附加位中。例如,将一个 8 位数符号扩展到 16 位数时,复制 8 位数的第 7 位到 16 位数的第 8 到 15 位。将 16 位半字符号扩展为字时,复制 16 位数的第 15 位到字的第 16 到 31 位。同样,将 32 位字符号扩展为 64 位双字时,将字的第 31 位复制到双字的上 32 位中。

操作符操作有符号值时,必须使用符号扩展。比如,将一个带符号的字节值与一个字长值相加时,必须先将字节值符号扩展到字长,然后再加起来。其他操作(特别是乘法和除法)可能需要符号扩展到 32 位。表 2-6 提供了多个符号扩展示例。

表 2-6:符号扩展示例

8 位 16 位 32 位
0x80 0xFF80 0xFFFFFF80
0x28 0x0028 0x00000028
0x9A 0xFF9A 0xFFFFFF9A
0x7F 0x007F 0x0000007F
0x1020 0x00001020
0x8086 0xFFFF8086

为了将无符号值扩展为更大的值,必须对该值进行零扩展。零扩展很简单——只需将零存储到更大操作数的高字节(HO 字节)中。例如,将 8 位值 0x82 扩展到 16 位时,向高字节前面添加一个零,得到 0x0082。表 2-7 提供了多个零扩展示例。

表 2-7:零扩展示例

8 位 16 位 32 位
0x80 0x0080 0x00000080
0x28 0x0028 0x00000028
0x9A 0x009A 0x0000009A
0x7F 0x007F 0x0000007F
0x1020 0x00001020
0x8086 0x00008086

您可以通过相同的方法进行零扩展,以支持双字或四字操作。

2.8 符号压缩和饱和

符号压缩是将具有一定位数的值转换为相同值,但位数较少,这稍微复杂一些。如果m < n,您并不总能将一个n位的数字转换为一个m位的数字。例如,考虑值–448。作为一个 16 位带符号数,它的十六进制表示是 0xFE40。这个数的绝对值对于 8 位值来说太大,因此无法将其压缩为 8 位;这样做会导致溢出。

要正确地进行符号压缩,必须丢弃的高位(HO)位必须全为 0 或 1,并且结果值的高位必须与您从数字中移除的每一个位相匹配。以下是一些示例(16 位到 8 位):

0xFF80 可以压缩为 0x80。

0x0040 可以压缩为 0x40。

0xFE40 无法压缩为 8 位符号数。

0x0100 无法压缩为 8 位。

如果您必须将较大的对象转换为较小的对象,并且愿意接受精度损失,可以使用饱和。通过饱和转换一个值时,如果较大的值不超出较小对象的范围,则将较大的值复制到较小的值中。如果较大的值超出较小值的范围,则通过将其设置为较小对象范围内的最大(或最小)值来裁剪该值。

例如,当将一个 16 位带符号整数转换为 8 位带符号整数时,如果 16 位值在–128 到+127 的范围内,您可以将 16 位对象的低字节复制到 8 位对象中。如果 16 位带符号值大于+127,则将值裁剪为+127,并将+127 存储到 8 位对象中。同样,如果值小于–128,则将最终的 8 位对象裁剪为–128。

尽管将值裁剪到较小对象的限制会导致精度损失,但有时这是可以接受的,因为替代方案是引发异常或以其他方式拒绝计算。对于许多应用程序,如音频或视频处理,裁剪后的结果仍然是可识别的,因此转换是一个合理的选择。

2.9 加载和存储字节和半字值

ARM 的内存是按字节寻址的。然而,到目前为止,本书中所有的加载和存储操作都是字或双字操作(由 ldr/str 寄存器的大小决定)。别担心:ARM CPU 提供了加载和存储字节、半字、字、双字,甚至四字的指令。

通用的 ldr 指令有以下几种形式:

ldr    `reg`, `mem`
ldrb   `reg`32, `mem`
ldrsb  `reg`, `mem`
ldrh   `reg`32, `mem`
ldrsh  `reg`, `mem`
ldrsw  `reg`64, `mem`

reg32 操作数只能是 32 位寄存器,而 reg64 操作数只能是 64 位寄存器。reg(无下标)操作数可以是 32 位或 64 位寄存器。

ldrb 和 ldrsb 指令从内存中加载一个字节到目标寄存器。由于寄存器总是 32 位或 64 位宽,当字节从内存加载到寄存器时,必须以某种方式进行扩展。ldrb 指令将内存中的字节零扩展到寄存器中。ldrsb 指令将内存中的字节符号扩展到寄存器中。零扩展仅适用于 32 位寄存器,但 ldrb 和 ldrh 指令会自动清除相应 64 位寄存器的高 32 位。如果你将一个字节或半字符号扩展到 32 位寄存器,这将清除相应 64 位寄存器的高 32 位。如果你希望将字节或半字扩展到整个 64 位寄存器中,请指定一个 64 位寄存器。

ldrh 和 ldrsh 指令类似地从内存中加载并扩展半字值,通过零扩展(ldrh)和符号扩展(ldrsh)。和之前一样,ldrh 指令接受一个 32 位寄存器,但它会自动在寄存器的整个 64 位范围内进行零扩展。

ldrsw 指令将从内存中获取一个 32 位有符号整数,并将其符号扩展到指定的 64 位寄存器。没有显式指令可以将 32 位扩展到 64 位;标准的 ldr 指令,如果操作数是 32 位寄存器,将自动完成此操作。

请注意,仅由标签组成的内存操作数(PC 相对寻址)仅对 ldr 和 ldrsw 指令有效。其他指令仅允许基于寄存器的寻址模式(例如,[X0])。

ldr{size} 指令非常适用于从内存中加载和扩展字节、半字和字值。如果要扩展的值位于另一个寄存器中,你不想将该寄存器存储到内存中,因此可以将该值扩展到不同的寄存器中。幸运的是,ARM 提供了一组指令,sxtb、sxth 和 sxtw,专门用于这种情况:

sxtb `reg`dest, `reg`src  // Sign-extends LO byte of `reg`src
sxth `reg`dest, `reg`src  // Sign-extends LO half word of `reg`src
sxtw `reg`dest, `reg`src  // Sign-extends LO word of `reg`src

sxtw 指令需要一个 64 位目标寄存器。sxtb、sxth 和 sxtw 指令需要 32 位源寄存器,无论目标寄存器的大小如何。

ARM 不提供任何显式指令来将一个寄存器零扩展到另一个寄存器。然而,你可以使用一些技巧来实现相同的效果。每当你将数据从一个寄存器移动到一个 32 位寄存器时,ARM 会自动将目标 64 位寄存器的高 32 位清零。你可以利用这种行为将任何较小的值零扩展到较大的值。

以下指令将 Wm 复制到 Wn,并在过程中清除 Xn 的高 32 位:

mov  w`n`, w`m`  // Zero-extends 32-bit W`m` into X`n`

以下指令将 Wm 中的值与 0xFFFF 进行按位与运算,然后将结果存储到 Wn 中,零扩展至 Xn 的高位:

and  w`n`, w`m`, #0xFFFF  // Zero-extends 16 bits to 64

最后,以下指令通过 Xn 将 Wm 的低字节零扩展:

and  w`n`, w`m`, #0xFF  // Zero-extends 8 bits to 64

将字节和半字存储到内存比加载要简单得多。ARM 在存储到内存时不支持收缩或饱和。因此,字节和半字存储指令有以下两种形式:

strb `reg`32, `mem`
strh `reg`32, `mem`

strb指令将指定寄存器的低字节存储到内存中。strh指令将寄存器的低半字存储到内存中。寄存器必须是 32 位寄存器(如果你想存储 64 位寄存器的低字节或低半字,只需指定 32 位寄存器即可;这会做同样的事情)。请注意,mem必须是基于寄存器的寻址模式(这些指令不允许使用 PC 相对寻址模式)。

2.10 控制转移指令

到目前为止,汇编语言的示例一直没有使用条件执行,即在执行代码时做出决策的能力。事实上,除了blret指令之外,我没有涉及任何影响汇编代码顺序执行的方法。然而,为了为本书的剩余部分提供有意义的示例,你很快就需要有条件地执行代码段的能力。暂时绕开加载和存储指令,本节简要介绍了条件执行以及将控制权转移到程序其他部分的相关内容。

2.10.1 分支

或许最好的起点是讨论 ARM 无条件控制转移指令:b指令。b指令的形式如下:

b `statementLabel`

其中,statementLabel是附加到你.text段中的机器指令上的标识符。b指令会立即将控制权转移到由标签前缀标记的语句。这在语义上等价于高级语言中的goto语句。

这是一个在mov指令前面的语句标签示例:

stmtLbl: mov x0, #55

像所有 Gas 符号一样,语句标签也有一个与之关联的地址:标签后面紧跟的机器指令的内存地址。

语句标签不必与机器指令位于同一物理源代码行。考虑以下示例:

anotherLabel:
   mov x0, #55

这个示例在语义上与前一个示例等价。绑定到anotherLabel的值(地址)是紧跟该标签之后的机器指令的地址。在这种情况下,依然是mov指令,即使该mov指令出现在下一行(它仍然跟随标签,并且标签和mov语句之间没有任何会生成代码的其他 Gas 语句)。

从技术上讲,你也可以跳转到过程标签而不是语句标签。然而,b指令不会设置返回地址;如果过程执行了ret指令,返回位置可能未定义。第五章将更详细地探讨返回地址。

由于 b 作为指令助记符的命名不太合适(正如我们在“B 是坏名字”一节中讨论的那样),本书将使用 b.al 指令来表示跳转到当前源文件中的代码,并将 b 保留用于那些跳转到 ±1MB 范围外代码的少数情况。

2.10.2 影响条件码标志的指令

在介绍 add、sub、and、orr、eor 和 neg 指令时,我指出它们通常有两种形式:

`instr`   `operands`
`instrs`  `operands`  // Only adds, subs, ands, and negs

带有 s 后缀的形式(例如 adds)将在指令执行完毕后更新 PSTATE 寄存器中的条件码标志。例如,adds 和 subs 指令将执行以下操作:

  • 如果在算术操作中发生无符号溢出,则设置进位标志,否则清除进位标志。

  • 如果发生有符号溢出,则设置溢出标志。

  • 如果操作结果为零,则设置零标志。

  • 如果操作结果为负(HO 位被设置),则设置负号(符号)标志。

虽然并非所有指令都支持 s 后缀,但许多执行某种计算的指令会允许此后缀。通过允许你选择哪些指令影响标志,ARM CPU 使你能够在执行一些你希望忽略其对标志影响的指令时,保留条件码。

正如其名字所示,这些条件码允许你测试特定的条件,并根据这些测试条件有条件地执行代码。下一节将描述如何测试条件码标志,并根据它们的设置做出决策。

2.10.3 有条件跳转

尽管 b.al/b 指令在汇编语言程序中不可或缺,但它不提供有条件地执行代码段的功能——因此它被称为无条件跳转。幸运的是,ARM CPU 提供了丰富的有条件跳转指令,允许有条件地执行代码。

这些指令测试 PSTATE 寄存器中的条件码位,以确定是否应该跳转。PSTATE 寄存器中有四个条件码位,供这些有条件跳转指令测试:进位、符号、溢出和零标志。

ARM CPU 提供了八条指令,分别测试这四个标志,如表 2-8 所示。条件跳转指令的基本操作是测试一个标志,检查它是设置(1)还是清除(0),如果测试成功,则跳转到目标标签。如果测试失败,则程序继续执行紧接在条件跳转指令之后的下一条指令。

表 2-8:测试条件码标志的有条件跳转指令

指令 说明
bcs label 如果进位标志被设置,则跳转。如果进位标志被设置(1),则跳转到标签;如果进位标志清除(0),则控制流跳到下一条指令。
bcc label 如果进位标志清除,则跳转。如果进位标志为清除(0),则跳转到标签;如果进位标志设置(1),则继续执行下一条指令。
bvs label 如果溢出标志已设置,则跳转到标签(1);如果溢出标志清除,则继续执行(0)。
bvc label 如果溢出标志清除,则跳转到标签(0);如果溢出标志设置,则继续执行(1)。
bmi label 如果是负数,则跳转到标签。如果负(符号)标志已设置(1),则跳转;如果符号标志清除(0),则继续执行。
bpl label 如果是正数(或零),则跳转到标签。如果负标志清除(0),则跳转;如果符号位设置(1),则继续执行。
beq label 如果相等,则跳转到标签。如果零标志已设置(1),则跳转;如果零标志清除(0),则继续执行。
bne label 如果不相等,则跳转到标签。如果零标志清除(0),则跳转;如果零标志设置(1),则继续执行。

出于历史原因,Gas 还允许类似 b.condition(例如,b.cs、b.cc、b.vs 和 b.vc)格式的条件分支助记符。这种格式基于 32 位 ARM 指令集,该指令集通过使用“点条件”后缀允许大多数数据处理指令的条件执行。尽管 64 位 ARM 指令集不再支持这些条件指令,但它仍然允许为分支指令使用点条件语法。由于不带句点的条件分支更容易输入,因此大多数人在编写 64 位 ARM 汇编语言时使用这种形式。Linux 下的 Gas 似乎不支持 bal,但支持 b.al,而 macOS 的汇编器似乎可以很好地支持 b.al。这就是本书使用 b.al 作为无条件分支的原因。

要使用条件分支指令,首先必须执行一条会影响一个或多个条件码标志的指令。例如,无符号算术溢出会设置进位标志;如果没有发生溢出,进位标志将被清除。因此,你可以在执行 adds 指令后使用 bcs 和 bcc 指令来检查计算过程中是否发生了无符号溢出。例如,以下代码使用 bcs 检查无符号溢出:

 lea  x0, int32Var
    ldr  w0, [x0]
    lea  x1, anotherVar
    ldr  w1, [x1]
    adds w0, w0, w1
    bcs  overflowOccured

// Continue down here if the addition did not
// produce an overflow.

    .
    .
    .

overflowOccured:

// Execute this code if the sum of int32Var and anotherVar
// does not fit into 32 bits.

如前所述,adds(以及 subs/negs)根据有符号/无符号溢出、零结果或负结果设置条件码。ands 指令将结果的 HO 位复制到负标志中,并在产生零/非零结果时设置/清除零标志。

2.10.4 cmp 和相应的条件分支

ARM 的 cmp 指令在与条件分支配合使用时非常有用。cmp 的语法是

cmp `left`, `right`

其中左操作数是寄存器(32 位或 64 位),右操作数可以是寄存器或小的立即数常量。该指令将左操作数与右操作数进行比较,并根据比较结果设置标志。然后,你可以使用条件分支指令根据比较结果转移控制。

尽管 cmp 指令没有 s 后缀,它仍然会设置条件码标志;事实上,这就是 cmp 存在的原因。技术上讲,cmp 并不是一个真正的指令,而是 subs 指令的别名(同义词),其目标操作数是 WZR 或 XZR。

执行比较指令后,你可能会问这些合理的问题:

  • 左操作数是否等于右操作数?

  • 左操作数是否不等于右操作数?

  • 左操作数是否小于右操作数?

  • 左操作数是否小于或等于右操作数?

  • 左操作数是否大于右操作数?

  • 左操作数是否大于或等于右操作数?

对于小于和大于的比较,你可能还会问:“这是有符号还是无符号比较?”

ARM 提供了在执行 cmp 后可以使用的条件分支来回答这些问题。表 2-9 列出了无符号比较的指令。

表 2-9:无符号条件分支

指令 测试的标志位 描述
beq Z = 1 如果相等则分支;如果不相等则跳过。比较后,如果第一个 cmp 操作数等于第二个操作数,将会执行此分支。
bne Z = 0 如果不相等则分支;如果相等则跳过。比较后,如果第一个 cmp 操作数不等于第二个操作数,将会执行此分支。
bhi C = 1 且 Z = 0 如果较大则分支;如果不较大则跳过。比较后,如果第一个 cmp 操作数大于第二个操作数,将会执行此分支。
bhs C = 1 如果较大或相同则分支;如果不较大或相同则跳过。比较后,如果第一个 cmp 操作数大于或等于第二个操作数,将会执行此分支。
blo C = 0 如果较小则分支;如果不较小则跳过。比较后,如果第一个 cmp 操作数小于第二个操作数,将会执行此分支。
bls C = 0 或 Z = 1 如果较小或相同则分支;如果不较小或相同则跳过。比较后,如果第一个 cmp 操作数小于或等于第二个操作数,将会执行此分支。

如果左操作数和右操作数包含有符号整数值,请使用 表 2-10 中的有符号分支。

表 2-10:有符号条件分支

指令 测试的标志位 描述
beq Z = 1 如果相等则分支;如果不相等则跳过。比较后,如果第一个 cmp 操作数等于第二个操作数,将会执行此分支。
bne Z = 0 如果不相等则分支;如果相等则跳过。比较后,如果第一个 cmp 操作数不等于第二个操作数,将会执行此分支。
bgt Z = 0 且 N = V 如果大于则分支;如果小于或等于则跳过。比较后,如果第一个 cmp 操作数大于第二个操作数,将会执行此分支。
bge N = V 如果大于或等于则跳转;如果小于则继续执行。在比较之后,如果第一个 cmp 操作数大于或等于第二个操作数,则会采取此分支。
blt N ≠ V 如果小于则跳转;如果大于或等于则继续执行。在比较之后,如果第一个 cmp 操作数小于第二个操作数,则会采取此分支。
ble N ≠ V 或 Z = 1 如果小于或等于则跳转;如果大于则继续执行。在比较之后,如果第一个 cmp 操作数小于或等于第二个操作数,则会采取此分支。

至于之前基于条件码的分支,Gas 除了表 2-9 和表 2-10 中的形式,还允许使用 b.condition 的分支形式。事实证明,如“测试的标志”一栏所示,bcs 和 bhs 指令是同义的,bcc 和 blo 指令也是同义的。

重要的是,cmp 指令仅为整数比较设置标志,这也适用于字符和其他可以用整数值编码的类型。具体来说,指令不会比较浮动点值并为浮动点比较设置标志。

有时,基于相反条件进行跳转是很方便的。例如,你可能有如下逻辑:

 cmp x0, x1
    // Branch to geLbl if X0 is not less than X1.

    // Fall through to this code if X0 < X1.
     .
     .
     .
// Branch here if NOT(X0 < X1) (that is, X0 >= X1).
geLbl:

当然,小于的相反是大于或等于,所以这个伪代码可以写成如下:

 cmp x0, x1
    bge geLbl

    // Fall through to this code if X0 < X1.
     .
     .
     .
// Branch here if NOT(X0 < X1) (that is, X0 >= X1).
geLbl:

然而,使用相反的分支来跳过你想在某个条件下执行的代码(例如小于)可能会使代码变得更难阅读。人们通常会将 bge 指令理解为“因为比较结果大于或等于,所以跳转到标签”,而不是“如果比较结果小于,则继续执行”。

为了帮助使这种逻辑更加清晰,aoaa.inc包含文件包含了几个相反分支的宏。表 2-11 列出了这些宏及其含义。

表 2-11:相反分支

相反分支 等价于 含义
bnhs blo 如果不高于或相等则跳转。在比较之后,如果第一个 cmp 操作数不高于或相等(无符号)第二个操作数,则会采取此分支。
bnhi bls 如果不高于则跳转。在比较之后,如果第一个 cmp 操作数不高于(无符号)第二个操作数,则会采取此分支。
bnls bhi 如果不低于或相等则跳转。在比较之后,如果第一个 cmp 操作数不低于或相等(无符号)第二个操作数,则会采取此分支。
bnlo bhs 如果不小于则跳转。在比较之后,如果第一个 cmp 操作数不小于(无符号)第二个操作数,则会采取此分支。
bngt ble 如果不大于则跳转。在比较之后,如果第一个 cmp 操作数不大于(有符号)第二个操作数,则会采取此分支。
bnge blt 如果不大于或等于则跳转。在比较后,如果第一个 cmp 操作数不大于或等于(带符号)第二个操作数,则执行此跳转。
bnlt bge 如果不小于则跳转。在比较后,如果第一个 cmp 操作数不小于(带符号)第二个操作数,则执行此跳转。
bnle bgt 如果不小于或等于则跳转。在比较后,如果第一个 cmp 操作数不小于或等于(带符号)第二个操作数,则执行此跳转。

你应当将这些相反分支的助记符理解为“在条件满足时跳过” (忽略 not 部分)。

2.11 移位与旋转

移位旋转 操作是另一类应用于比特串的逻辑操作。这两个类别可以进一步细分为左移、左旋转、右移和右旋转。

左移操作 将每个比特在比特串中向左移动一位,如 图 2-8 所示。

图 2-8:左移操作

比特 0 移入比特位置 1,原先在比特位置 1 的值移入比特位置 2,依此类推。你会将一个 0 移入比特 0,原来高位比特的值将丢失。

ARM 提供了一条逻辑左移指令 lsl,用于执行这个有用的操作。lsl 的语法如下:

lsl  `dest`, `source`, `count` // Does not affect any flags

计数操作数可以是一个寄存器,或者是范围在 0 到 n 之间的立即数,其中 n 是目标操作数位数减 1(例如,对于 32 位操作数,n = 31,对于 64 位操作数,n = 63)。目标和源操作数是寄存器。

当计数操作数为 1 时(无论是立即数还是寄存器中的值),lsl 指令会执行图 2-9 所示的操作。

图 2-9:左移操作

如果计数值为 0,则不会发生移位,值保持不变。如果计数值大于 1,lsl 指令会将指定数量的比特位移(将 0 移入低位)。注意,lsl 指令不会影响任何标志位。

将一个值左移一位,相当于将其乘以基数(进制)。例如,将一个十进制数左移一位(在数字右边添加一个 0)实际上是将其乘以 10(基数):

1234 shl 1 = 12340
// (shl 1 means shift one digit position to the left.)

因为二进制数的基数是 2,左移操作会使其乘以 2。如果你将一个值左移 n 次,它就相当于将该值乘以 2^n

右移 操作的原理相同,只不过数据是向相反方向移动。对于一个字节值,比特 7 移入比特 6,比特 6 移入比特 5,比特 5 移入比特 4,依此类推。在右移过程中,你会将一个 0 移入比特 7(见 图 2-10)。

图 2-10:右移操作

正如你所期望的,ARM 提供了一个 lsl 指令,可以将位向目标操作数右移。其语法类似于 lsl 指令:

lsr  `dest`, `source`, `count` // Does not affect any flags

这个指令将一个 0 移入目标操作数的 HO 位,并将其他位向右移动一个位置(即,从较高的位数移到较低的位数)。

因为左移相当于乘以 2,所以右移大致相当于除以 2(或者一般来说,除以数字的基数)。如果你执行n次右移操作,你将把该数字除以 2^n

然而,右移仅相当于无符号除以 2。例如,如果你将 254(0xFE)的无符号表示右移一个位置,你得到 127(0x7F),这正是你所期望的结果。然而,如果你将–2(0xFE)的二进制补码表示右移一个位置,你得到 127(0x7F),这是不正确的。这个问题发生是因为你将一个 0 移入了第 7 位。如果第 7 位之前是 1,那么你就将其从负数改为正数——在除以 2 时,这不是一个好做法。

为了将右移用作除法运算符,本章必须定义第三种右移操作:算术右移。不需要算术左移;标准的左移操作适用于有符号和无符号数字,前提是没有溢出发生。

算术右移的工作方式与普通的右移操作(逻辑右移)相同,除了它不是将 0 移入 HO 位,而是将 HO 位的值复制回自身。也就是说,右移操作不会修改 HO 位,如图 2-11 所示。

图 2-11:算术右移操作

算术右移通常会产生你预期的有符号整数结果。例如,如果你对–2(0xFE)执行算术右移操作,结果是–1(0xFF)。然而,这个操作总是将数字四舍五入到最接近的整数,该整数是小于或等于实际结果的。例如,如果你对–1(0xFF)执行算术右移操作,结果是–1,而不是 0。因为–1 小于 0,所以算术右移操作会朝–1 方向舍入。这不是算术右移操作的错误;它只是采用了一个不同(但有效的)整数除法定义。

ARM-64 提供了一个算术右移指令,asr(算术右移)。该指令的语法几乎与 lsl 相同:

asr  `dest`, `source`, `count` // Does not affect any flags

操作数的常规限制适用。如果计数为 1,则此指令按图 2-12 所示进行操作。

图 2-12:asr dest, source, #1 操作

如果计数值为 0,则不发生移位,值保持不变。如果计数值大于 1,则 asr 指令将指定数量的位进行移位(将 0 移入 LO 位置)。

左旋右旋操作与移位左和移位右操作类似,不同之处在于,从一端移出的位会被重新移入另一端。图 2-13 展示了这些操作的示意图。

图 2-13:左旋和右旋操作

ARM 提供了 ror(右旋)指令,但没有左旋指令。右旋的语法与移位指令类似:

ror  `dest`, `source`, `count` // Does not affect any flags

图 2-14 展示了该指令在寄存器上的操作。请注意,这个指令不会影响任何标志。如果计数值为 0,则不发生旋转,值保持不变。如果计数值大于 1,旋转指令将指定数量的位进行旋转(将 0 移入适当的位置)。

图 2-14:ror dest, source, #1 操作

如果你绝对需要进行 rol 操作,可以通过其他指令(在某种程度上)进行合成。第八章对此进行了更详细的说明。

2.12 位域与打包数据

尽管 ARM 在字节、半字、字和双字数据类型上操作最为高效,但偶尔你会需要处理使用 8、16、32 或 64 以外的位数的数据类型。你可以将非标准数据大小扩展为下一个更大的 2 的幂次方(例如将 22 位值扩展为 32 位值);这种方法速度很快,但如果你有一个包含这些值的大数组,则大约有 31% 的内存会浪费(每个 32 位值中有 10 位是浪费的)。然而,假设你将这 10 位重新用于其他目的。通过打包这些单独的 22 位和 10 位值到一个 32 位值中,你就不会浪费任何空间。

例如,考虑一个日期格式为 04/02/01。表示这个日期需要三个数值:月、日和年值。当然,月份的取值范围是 1 到 12。表示月份至少需要 4 位,最多可以表示 16 种值。天数范围是 1 到 31,这意味着表示天数需要 5 位,最多表示 32 种值。年值,假设你处理的是 0 到 99 之间的值,需用 7 位表示,可以表示最多 128 个值。这意味着我们需要 2 字节来存储整个日期,因为 4 + 5 + 7 = 16 位。

换句话说,你可以将日期数据打包到 2 字节中,而不是使用 3 字节(如果每个月、日和年值都使用单独的字节)。这样每存储一个日期就节省了 1 字节的内存,如果你需要存储多个日期,这将带来显著的节省。这些位可以按 图 2-15 中所示的方式进行排列。

图 2-15:短打包日期格式(2 字节)

在图中,MMMM代表组成月份值的 4 个位,DDDDD代表组成日期的 5 个位,YYYYYYY代表组成年份的 7 个位。每一组表示数据项的位称为位字段。例如,2001 年 4 月 2 日将表示为 0x4101:

0100      00010   0000001  = 0100_0001_0000_0001b or 0x4101
4         2       01

尽管打包值是节省空间的(也就是说,它们有效利用了内存),但它们在计算上是低效的(很慢!)。这是因为解包打包到各个位字段中的数据需要额外的指令。这些指令需要额外的执行时间和额外的字节来存储指令;因此,你必须仔细考虑打包的数据字段是否真的能为你节省空间。列表 2-4 中的示例程序展示了打包和解包这种 16 位日期格式所付出的努力。

// Listing2-4.S
//
// Demonstrate packed data types.

#include "aoaa.inc"

             .equ    NULL, 0         // Error code
             .equ    maxLen, 256     // Max input line size

 .data

saveLRMain:  .dword  0
saveLRRN:    .dword  0

ttlStr:      .asciz  "Listing 2-4"
moPrompt:    .asciz  "Enter current month: "
dayPrompt:   .asciz  "Enter current day: "

yearPrompt:  .ascii  "Enter current year "
             .asciz  "(last 2 digits only): "

packed:      .ascii  "Packed date is %04x = "
             .asciz  "%02d/%02d/%02d\n"

theDate:     .asciz  "The date is %02d/%02d/%02d\n"

badDayStr:   .ascii  "Bad day value was entered "
             .asciz  "(expected 1-31)\n"

badMonthStr: .ascii "Bad month value was entered "
             .asciz "(expected 1-12)\n"

badYearStr:  .ascii "Bad year value was entered "
             .asciz "(expected 00-99)\n"

// These need extra padding so they can be printed
// as integers. They're really byte (and word) values.

month:      .dword  0
day:        .dword  0
year:       .dword  0
date:       .dword  0

m:          .dword  0
d:          .dword  0
y:          .dword  0

input:      .fill    maxLen, 0

            .text
            .align  2       // Word-align code
            .extern printf
            .extern readLine
            .extern strtol

// Return program title to C++ program:

            .global getTitle
getTitle:
            lea     x0, ttlStr
            ret

// Here's a user-written function that reads a numeric value from
// the user:
//
// int readNum(char *prompt);
//
// A pointer to a string containing a prompt message is passed in
// the X0 register.
//
// This procedure prints the prompt, reads an input string from
// the user, then converts the input string to an integer and
// returns the integer value in X0.

readNum:
            lea     x1, saveLRRN
            str     lr, [x1]        // Save return address.

// Must set up stack properly (using this "magic" instruction)
// before you can call any C/C++ functions:

            sub     sp, sp, #64

// Print the prompt message. Note that the prompt message was
// passed to this procedure in X0; we're just passing it on to
// printf:

            bl      printf

// Set up arguments for readLine and read a line of text from
// the user. Note that readLine returns NULL (0) in RAX if there
// was an error.

            lea     x0, input
            mov     x1, #maxLen
            bl      readLine

// Test for a bad input string:

            cmp     x0, #NULL
            beq     badInput

// Okay, good input at this point. Try converting the string
// to an integer by calling strtol. The strtol function returns
// 0 if there was an error, but this is a perfectly fine
// return result, so we ignore errors.

            lea     x0, input       // Ptr to string
            mov     x1, #NULL       // No end string pointer
            mov     x2, #10         // Decimal conversion
            bl      strtol          // Convert to integer.

badInput:
            add     sp, sp, #64     // Undo stack setup.
            lea     x1, saveLRRN    // Restore return address.
            ldr     lr, [x1]
            ret

// Here is the "asmMain" function:
            .global asmMain
asmMain:
            sub     sp, sp, #64     // Magic instruction
            lea     x0, saveLRMain
            str     lr, [x0]

// Read the date from the user. Begin by reading the month:

            lea     x0, moPrompt
            bl      readNum

// Verify the month is in the range 1..12:

            cmp     x0, #1
            blo     badMonth
            cmp     x0, #12
            bhi     badMonth

// Good month, save it for now:

            lea     x1, month
            strb    w0, [x1]    // 1..12 fits in a byte.

// Read the day:

            lea     x0, dayPrompt
            bl      readNum

// We'll be lazy here and verify only that the day is in
// the range 1..31.

            cmp     x0, #1
            blo     badDay
            cmp     x0, #31
            bhi     badDay

// Good day, save it for now:

            lea     x1, day
            strb    w0, [x1]    // 1..31 fits in a byte.

// Read the year:

            lea     x0, yearPrompt
            bl      readNum

// Verify that the year is in the range 0..99:

            cmp     x0, #0
            blo     badYear
 cmp     x0, #99
            bhi     badYear

// Good year, save it for now:

            lea     x1, year
            strb    w0, [x1] // 0..99 fits in a byte.

// Pack the data into the following bits:
//
//  15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
//   m  m  m  m  d  d  d  d  d  y  y  y  y  y  y  y

            lea     x0, month
            ldrb    w1, [x0]
            lsl     w1, w1, #5

            lea     x0, day
            ldrb    w2, [x0]
            orr     w1, w1, w2
            lsl     w1, w1, #7

            lea     x0, year
            ldrb    w2, [x0]
            orr     w1, w1, w2

            lea     x0, date
            strh    w1, [x0]

// Print the packed date:

            lea     x0, packed
            vparm2  date
            vparm3  month
            vparm4  day
            vparm5  year
            bl      printf

// Unpack the date and print it:

            lea     x0, date
            ldrh    w1, [x0]

            // Extract month:

            lsr     w2, w1, #12
            lea     x0, m
            strb    w2, [x0]

            // Extract day:

            lsr     w3, w1, #7
            and     w3, w3, #0x1f
            lea     x0, d
            strb    w3, [x0]

 // Extract year:

            and     w1, w1, #0x7f
            lea     x0, y
            strb    w1, [x0]

            lea     x0, theDate
            vparm2  m
            vparm3  d
            vparm4  y
            bl      printf

            b.al    allDone

// Come down here if a bad day was entered:

badDay:
            lea     x0, badDayStr
            bl      printf
            b.al    allDone

// Come down here if a bad month was entered:

badMonth:
            lea     x0, badMonthStr
            bl      printf
            b.al    allDone

// Come here if a bad year was entered:

badYear:
            lea     x0, badYearStr
            bl      printf

allDone:
            add     sp, sp, #64
            lea     x0, saveLRMain
            ldr     lr, [x0]
            ret     // Returns to caller

以下是构建并运行此程序的结果:

$ ./build Listing2-4
$ ./Listing2-4
Calling Listing2-4:
Enter current month: 2
Enter current day: 4
Enter current year (last 2 digits only): 56
Packed date is 2238 = 02/04/56
The date is 02/04/56
Listing2-4 terminated

为 Y2K(2000 年)所引发的臭名昭著的问题让每个人都明白,使用仅限于 100 年(甚至 127 年)的日期格式是非常愚蠢的。如果你太年轻,无法记得这一场灾难,那么可以告诉你,20 世纪中后期的程序员通常只在日期中编码年份的最后两位数字。当 2000 年来临时,这些程序无法区分像 2024 年和 1924 年这样的日期。

为了避免这个问题,并使列表 2-4 中的打包日期格式具备未来兼容性,你可以将格式扩展到 4 字节,打包成一个双字变量,如图 2-16 所示。(正如你将在第三章和第四章中看到的那样,你应该始终尝试创建长度为 2 的偶数次幂的数据对象——即 1 字节、2 字节、4 字节、8 字节,以此类推——否则你将遭遇性能上的损失。)

图 2-16:长打包日期格式(4 字节)

现在,月份和日期字段分别由 8 个位组成,因此它们可以从字中提取为字节对象。这留下了 16 个位给年份,支持 65,536 年的范围。通过重新排列这些位,使得年份字段位于高位(HO)位置,月份字段位于中位位置,日期字段位于低位(LO)位置,长日期格式允许你轻松比较两个日期,看看一个日期是否小于、等于或大于另一个日期。请考虑以下代码:

 lea x0, Date1  // Assume Date1 and Date2 are words.
     ldr x1, [x0]   // Using the long packed-date format
     lea x0, Date2
     ldr x2, [x0]
     cmp x1, x2
     ble d1LEd2

       // Do something if Date1 > Date2.

d1LEd2:

如果你将不同的日期字段保存在单独的变量中,或者以不同的方式组织字段,那么你就不能像短的打包日期格式那样轻松地比较 Date1 和 Date2。因此,这个例子展示了即使你没有意识到节省空间,打包数据的另一个原因:它可以使某些计算更加方便甚至更高效(这与通常打包数据时的情况相反)。

压缩数据类型的实际应用实例比比皆是。你可以将八个布尔值压缩成一个字节,将两个 BCD 数字压缩成一个字节,等等。一个经典的压缩数据示例是 PSTATE 寄存器(参见图 2-17)。这个寄存器将四个重要的布尔对象以及 12 个重要的系统标志压缩成一个 32 位寄存器。

图 2-17:PSTATE 寄存器作为压缩布尔数据

通常,你会通过使用条件跳转指令来访问条件码标志。偶尔,你可能需要操作 PSTATE 寄存器中的单个条件码位。你可以使用 msr(移至系统寄存器)和 mrs(移系统寄存器)指令来实现这一点。

msr  `systemReg`, `reg`
mrs  `reg`, `systemReg`

其中,reg 是 ARM 的 64 位通用寄存器之一,systemReg 是一个特殊的系统寄存器名称。这里关注的系统寄存器是 NZCV,它以条件码标志命名。

以下指令将 PSTATE 寄存器中的第 28 到 31 位复制到 X0 中的相应位,并将 0 复制到 X0 中的所有其他位:

mrs  x0, nzcv

这条指令将 X0 中的第 28 到 31 位复制到 PSTATE 中的条件码位(不影响 PSTATE 中的其他位):

msr  nzcv, x0

如果你想显式设置进位标志,而不影响其他任何条件码标志,可以按如下方式进行:

mrs  x0, nzcv
orr  x0, x0, #0x20000000  // Carry is in bit 29; set it.
msr  nzcv, x0

这条指令将一个 1 位按位或到 PSTATE 寄存器的进位标志中。

2.13 IEEE 浮点格式

回到 1976 年,当英特尔计划为其新的 8086 微处理器引入浮点协处理器时,它聘请了能找到的最优秀的数值分析师来设计浮点格式。那个人接着又聘请了另外两位该领域的专家,他们三人——威廉·卡汉、杰罗姆·库嫩和哈罗德·斯通——共同设计了英特尔的浮点格式。他们在设计 KCS 浮点标准时表现得非常出色,以至于电气与电子工程师协会(IEEE)采纳了这个格式作为其浮点格式。这个格式已经成为包括 Arm 在内的 CPU 厂商使用的标准。

IEEE-754 标准的单精度和双精度格式对应于 C 语言中的 float 和 double 类型,或者 FORTRAN 中的 real 和 double-precision 类型。这些相同的格式也适用于 ARM 汇编语言程序员。

2.13.1 单精度格式

单精度格式使用 24 位的反码尾数、8 位的超出 127 的指数以及 1 位符号位。反码表示法由符号位和一个无符号的二进制数字组成,符号位表示该二进制数的符号。尾数(表示有效数字的部分)通常表示从 1.0 到接近 2.0 的值。尾数的 HO 位始终假定为 1,表示二进制点左边的值。(二进制点小数点相同,只是它出现在二进制数中,而不是十进制数中。)其余的 23 个位(分数部分)出现在二进制点的右侧。

因此,尾数表示的值是:

1.`mmmmmmm mmmmmmmm`

mmmm 字符表示尾数的 23 位。由于尾数的 HO 位始终是 1,单精度格式实际上并不在浮点数的 32 位中存储这一位。这个 HO 位被称为隐式位

因为你正在处理二进制数字,二进制点右侧的每个位置表示一个值(0 或 1)乘以 2 的连续负幂。隐式的 1 位始终乘以 2⁰,即 1。这就是为什么尾数总是大于或等于 1.0 的原因。即使其他尾数位全是 0,隐式的 1 位也始终给我们值 1.0。当然,即使二进制点后面有几乎无限多个 1 位,它们加起来仍然不会等于 2.0。这就是为什么尾数能表示范围从 1.0 到接近 2.0 的值。

隐式位始终为 1 有一个例外:IEEE 浮点格式支持非标准化值,其中 HO 位不是 0。但是,本书通常忽略非标准化值。

尽管在 1.0 和 2.0 之间有无限多个值,但你只能表示其中的 800 万个值,因为格式使用了一个 23 位的尾数(隐式的第 24 位始终为 1)。这就是浮点运算不精确的原因:你在涉及单精度浮点值的计算中被限制为固定数量的位。

如前所述,尾数使用反码格式,而不是二补码来表示有符号值。这意味着尾数的 24 位值仅仅是一个无符号的二进制数,符号位决定该值是正数还是负数。反码数有一个不寻常的特性,即 0.0 有两个表示(符号位设置或未设置)。通常,这对于设计浮点软件或硬件系统的人来说很重要。本书假设值 0.0 的符号位始终未设置。

为了表示超出 1.0 到接近 2.0 范围的值,浮点数格式的指数部分发挥作用。浮点数格式将 2 的指数指定的次方值进行提升,然后将尾数乘以该值。指数是 8 位,采用超额-127 格式存储。在超额-127 格式中,指数 0 由值 127(0x7F)表示,负指数的值在 1 到 126 之间,正指数的值在 128 到 254 之间(0 和 255 保留用于特殊情况)。要将指数转换为超额-127 格式,只需将 127 加到指数值上。使用超额-127 格式使得浮点数值的比较变得更加简单。

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

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

在具有 24 位尾数的情况下,您将获得大约六个半(十进制)数字的精度(半个数字的精度意味着前六位数字可以在 0 到 9 的范围内,但第七位数字只能在 0 到x的范围内,其中x < 9,并且通常接近 5)。但请注意,只有六位数字是有保证的。使用 8 位超额-127 指数时,单精度浮点数的动态范围大约是 2 ^(± 127),或大约是 10 ^(± 38)。这个动态范围是最小和最大正值之间的大小差异。

虽然单精度浮点数在许多应用中完全适用,但其精度和动态范围有些有限,不适合许多金融、科学及其他应用。此外,在长时间的计算过程中,单精度格式的有限精度可能会引入严重的误差。

2.13.2 双精度格式

双精度格式有助于克服单精度浮点数的问题。双精度格式使用两倍空间,具有 11 位超额-1,023 指数和 53 位尾数(隐含有 1 个高位),加上一个符号位。双精度浮点数值的格式如图 2-19 所示。

图 2-19:64 位双精度浮点数格式

第 53 位尾数位是隐含的,始终为 1。双精度格式提供大约 10 ^(± 308)的动态范围,并至少提供 15 位的精度,足以满足大多数应用的需求。

2.14 规范化浮点数值

为了在计算过程中保持最大精度,大多数计算使用标准化值。一个 标准化的浮点值 是其高位尾数位包含 1 的值。几乎所有非标准化的值都可以被标准化:将尾数位向左移动,并递减指数,直到尾数的高位出现 1。记住,指数是二进制指数。每次增加指数时,浮点值会乘以 2。同样,每次递减指数时,浮点值会除以 2。同理,将尾数向左移动一位会将浮点值乘以 2;而将尾数向右移动则会将浮点值除以 2。因此,将尾数向左移动一位 并且 递减指数,实际上并不会改变浮点数的值。

保持浮点数标准化能够保持计算的最大精度。如果尾数的高位 n 位全部为 0,那么尾数的可用精度就减少了这么多位。因此,涉及标准化值的浮点计算会更加精确。

在两个重要情况下,浮点数无法标准化。首先,浮点值 0.0 不能标准化,因为 0.0 的表示在尾数中没有 1 位。然而,这并不是问题,因为你可以仅用一个位精确表示 0.0。

在第二种情况下,尾数中的一些高位是 0,但偏置指数也为 0(并且无法递减指数以标准化尾数)。IEEE 标准允许特殊的 非标准化 值来表示这些较小的值(替代方法是将这些值下溢为 0)。尽管使用非标准化值比发生下溢时能得到更好的浮点计算结果,但请注意,非标准化值提供的精度较低。有些文献使用 次正常 这个术语来描述非标准化值。

2.14.1 非数值

IEEE 浮点标准识别四个特殊的非数值:-无穷大、+无穷大,以及两个特殊的“非数值”(NaN)。对于这些特殊的数字,指数域填充所有 1 位。

如果指数全部为 1 位,且尾数全部为 0 位(不包括隐含位),则该值为无穷大。符号位为 0 时表示 +无穷大,符号位为 1 时表示 -无穷大。

如果指数全部为 1 位,且尾数不是全部 0 位,则该值为无效数字(在 IEEE 754 术语中称为 NaN)。NaN 代表非法操作,例如尝试对负数取平方根。

无序比较会在任何一个操作数(或两个操作数)是 NaN 时发生。由于 NaN 的值是不确定的,因此它们不可比较。任何尝试进行无序比较的操作通常会导致异常或某种错误。另一方面,有序比较则涉及两个操作数,其中没有一个是 NaN。

2.14.2 Gas 对浮点值的支持

Gas 提供了几种数据声明,以支持在汇编语言程序中使用浮点数据。Gas 浮点常量允许以下语法:常量以可选的 + 或 − 符号开始,表示尾数的符号(如果没有这个符号,Gas 假定尾数为正)。接着是一个或多个十进制数字,然后是一个小数点和零个或多个十进制数字。之后可选地跟着一个 e 或 E,e 或 E 后面可以跟一个可选的符号(+ 或 −)和一个或多个十进制数字。

小数点或 e/E 必须存在,以区分浮点字面常量和整数或无符号字面常量。以下是一些合法的浮点字面常量示例:

1.234  3.75e2  -1.0  1.1e-1  1.e+4  0.1  -123.456e+300  +25.0e0

浮点字面常量必须以一个十进制数字开头,因此你必须在程序中使用 0.1 而不是 .1。

要声明一个浮点变量,可以使用 .single 或 .double 数据类型。除了使用这些类型声明浮点变量而不是整数外,它们的使用几乎与 .byte、.word、.dword 等类型相同。以下示例展示了这些声明及其语法:

 .data
fltVar1:  .single  0.0
fltVar1a: .single  2.7
pi:       .single  3.14159
DblVar:   .double  0.0
DblVar2:  .double  1.23456789e+10
DPVar:    .double  -1.0e-104
IntAsFP:  .double  -123

和往常一样,本书使用 C/C++ 的 printf() 函数将浮点值输出到控制台。确实,也可以编写汇编语言例程来实现相同的输出,但 C 标准库提供了一种便捷的方式,避免编写复杂的代码(至少在第九章之前是如此)。

浮点算术与整数算术不同;你不能使用 ARM 的 add 和 sub 指令来操作浮点值。本章仅介绍浮点格式;有关浮点算术和一般浮点操作的更多信息,请参见第六章。

在此期间,让我们考虑一些其他的数据格式。

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

尽管整数和浮点数格式可以满足大多数程序的数字需求,但在某些特殊情况下,其他数值表示形式会更为方便。本节扩展了之前介绍的 BCD 格式的定义。尽管 ARM CPU 并不提供对 BCD 的硬件支持,但它仍然是一些软件使用的常见格式,BCD 算术由程序员编写的软件函数提供。

BCD 值是一个由若干个半字节组成的序列,每个半字节表示一个 0 到 9 之间的值。通过一个字节,你可以表示包含两个十进制数字的值,或者 0 到 99 之间的值。图 2-20 显示了在一个字节中,由 4 个比特表示的两个 BCD 数字。

图 2-20:内存中两位 BCD 数据表示

如你所见,BCD 存储并不是特别节省内存。例如,一个 8 位 BCD 变量可以表示 0 到 99 之间的值,而同样的 8 位,当存储一个二进制值时,可以表示 0 到 255 之间的值。同样,16 位的二进制值可以表示 0 到 65,535 之间的值,而 16 位的 BCD 值只能表示这些值的大约六分之一(0 到 9,999)。

然而,转换 BCD 值在内部数值表示和字符串表示之间是很容易的,例如,使用 BCD 在硬件中通过刻度盘或旋钮对多位十进制值进行编码。基于这两个原因,你很可能会看到人们在嵌入式系统(如烤面包机、计算器、闹钟和核反应堆)中使用 BCD,但在通用计算机软件中很少使用。

不幸的是,ARM 上的所有 BCD 操作都必须通过软件函数来完成,因为 BCD 算术并未集成到 ARM 的硬件中。因此,涉及 BCD 算术的计算可能会很慢。由于 BCD 数据类型非常专业,只在少数几种情况中使用(例如,在嵌入式系统中),本书将不再进一步讨论它。

2.16 字符

也许在个人计算机中最重要的数据类型是字符数据类型。字符指的是通常是非数字实体的人类或机器可读的符号。具体来说,字符是任何你通常可以在键盘上输入(包括可能需要多个按键才能产生的符号)或在视频显示器上显示的符号。

字母(字母字符)、标点符号、数字、空格、制表符、回车符(ENTER)、其他控制字符和其他特殊符号都是字符。数字字符与数字是不同的:字符 1 与数字 1 的值是不同的。计算机(通常)使用两种内部表示法来处理数字字符(0,1,...,9)和数字值 0 到 9。

大多数计算机系统使用单字节或多字节序列以二进制形式编码各种字符。Linux 和 macOS 使用 ASCII 或 Unicode 编码来表示字符。本节讨论了 ASCII 和 Unicode 字符集以及 Gas 提供的字符声明功能。

2.16.1 ASCII 字符编码

ASCII 字符集将 128 个文本字符映射到无符号整数值 0 到 127(0 到 0x7F)。虽然字符与数字值的确切映射是任意的且无关紧要,但你必须使用标准化的代码进行此映射,以便在与其他程序和外部设备通信时,大家都使用相同的“语言”。ASCII 是一个标准化的代码:如果你使用 ASCII 代码 65 表示字符 A,那么你就知道当你将数据传输给外部设备(如打印机)时,该设备会正确地将该值解读为字符 A。

尽管存在一些重大缺陷,ASCII 已经成为计算机系统和程序之间数据交换的标准。大多数程序都可以接收和生成 ASCII 数据。由于你将在汇编语言中处理 ASCII 字符,我建议你学习字符集的布局,并记住一些关键的 ASCII 代码(例如,0、A、a 等)。有关所有 ASCII 字符代码的列表,请参见附录 A。

今天,Unicode(特别是 UTF-8 编码)正在迅速取代 ASCII,因为 ASCII 字符集不足以处理国际字母表和其他特殊字符,正如你将在第十四章中看到的那样。然而,大多数现代代码仍然使用 ASCII,因此你应该熟悉它。

ASCII 字符集被划分为四组,每组 32 个字符。前 32 个字符,ASCII 代码 0 到 0x1F(31),形成了一组特殊的不可打印字符,即控制字符。它们被称为控制字符,因为它们执行各种打印机/显示控制操作,而不是显示符号。例子包括回车符,它将光标定位到当前行的左侧;换行符,它将光标在输出设备上移动到下一行;以及退格符,它将光标向左移动一个位置。(历史上,回车符指的是打字机上用来移动纸张的纸架:将纸架移动到最右侧可以使下一个输入的字符出现在纸张的左侧。)不幸的是,不同的控制字符在不同的输出设备上执行不同的操作。输出设备之间缺乏标准化。要了解控制字符如何影响特定设备,请查阅其手册。

第二组 32 个 ASCII 字符代码包含各种标点符号、特殊字符和数字字符。这组中最显著的字符包括空格字符(ASCII 代码 0x20)和数字字符(ASCII 代码 0x30 到 0x39)。

第三组 32 个 ASCII 字符包含大写字母字符。字符 A 到 Z 的 ASCII 代码范围是 0x41 到 0x5A(65 到 90)。由于只有 26 个字母字符,其余 6 个代码用来表示各种特殊符号。

第四组也是最后一组 32 个 ASCII 字符代码,表示小写字母符号、5 个额外的特殊符号和另一个控制字符(删除)。小写字母符号使用的 ASCII 码从 0x61 到 0x7A。如果你将大小写字符的代码转换为二进制,你会发现大写符号和其对应的小写符号在恰好一位上有所不同。例如,考虑图 2-21 中 E 和 e 的字符代码。

图 2-21:E 和 e 的 ASCII 码

大小写字母唯一的区别就在于第 5 位。大写字母在第 5 位总是 0;小写字母总是在第 5 位为 1。你可以利用这个特性快速进行大小写转换。你可以通过将第 5 位设置为 1 将大写字母转换为小写字母,或者通过将第 5 位设置为 0 将小写字母转换为大写字母。

的确,第 5 位和第 6 位决定了你所在的 ASCII 字符集中的四个组中的哪一组,正如表 2-12 所示。例如,你可以通过将第 5 位和第 6 位设置为 0,将任何大写或小写字母(或相应的特殊字符)转换为其等效的控制字符。

表 2-12:ASCII 组

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

请查看表 2-13 中的数字字符的 ASCII 码。

表 2-13:数字字符的 ASCII 码

字符 十进制 十六进制
0 48 30h
1 49 31h
2 50 32h
3 51 33h
4 52 34h
5 53 35h
6 54 36h
7 55 37h
8 56 38h
9 57 39h

ASCII 码的低位半字节是所表示数字的二进制等效表示。通过去除(即设置为 0)数字字符的高位半字节,你可以将该字符代码转换为相应的二进制表示。相反,你可以通过简单地将高位半字节设置为 3,将 0 到 9 范围内的二进制值转换为其 ASCII 字符表示。你可以使用与操作将高位比特强制为 0;同样,你可以使用或操作将高位比特强制为 0b0011(3)。

不幸的是,你不能通过简单地去除每个数字字符中的高位半字节来将一个数字字符串转换为其等效的二进制表示。以这种方式转换 123(0x31, 0x32, 0x33)会得到 3 个字节,或者 0x010203,但 123 的正确值是 0x7B。前述的转换方法仅适用于单个数字。

2.16.2 Gas 对 ASCII 字符的支持

Gas 提供对字符变量和字面量常量的支持,供你在汇编语言程序中使用。Gas 中的字符字面常量由一个字符组成,并用一对撇号(或单引号)括起来:

'A'

从技术上讲,Gas 中的字符常量由一个撇号后跟一个字符组成。Gas 还允许第二种形式,即字符被撇号包围。然而,macOS 汇编器只支持后一种形式,因此本书仅使用这种形式,以确保所有示例代码能够在两种系统上汇编。

要将撇号表示为字符常量,请使用反斜杠字符后跟撇号。例如:

'\''

你还可以在字符常量中使用其他转义字符序列。有关详细信息,请参见第 1.7 节“在 Gas 中声明内存变量”,第 16 页。

要在 Gas 程序中声明一个字符变量,请使用.byte 声明。例如,以下声明演示了如何声明一个名为 UserInput 的变量:

 .data
UserInput: .byte  0

这个声明预留了 1 字节的存储空间,你可以用它来存储任何字符值。你还可以按如下方式初始化字符变量:

 .data
TheCharA:     .byte 'A'
ExtendedChar: .byte 128  // Character code greater than 0x7F

因为字符变量是 8 位对象,所以你可以像操作任何 8 位值一样操作它们。你可以将字符变量移动到寄存器中,并将寄存器的低字节存储到字符变量中。

2.17 Gas 对 Unicode 字符集的支持

不幸的是,ASCII 仅支持 128 个字符编码。即使你将定义扩展到 8 位(正如 IBM 在原始 PC 上所做的那样),你也只能使用 256 个字符。这对于现代的跨国、多语言应用来说太小了。回到 1990 年代,几家公司开发了 ASCII 的扩展,称为Unicode,它使用了 2 字节的字符大小。因此,原始的 Unicode 支持最多 65,536 个字符编码。

尽管原始 Unicode 标准考虑得非常周全,但系统工程师发现即使是 65,536 个符号也不足够。今天,Unicode 定义了 1,112,064 个可能的字符(代码点),并使用可变长度的字符格式进行编码。

不幸的是,Gas 几乎不支持源文件中的 Unicode 文本。当然,如果你有一个支持编辑 UTF-8 源文件的文本编辑器,Gas 会接受字符和字符串文字中的 UTF-8 字符。然而,除了这些,Gas 可能不会对 Unicode 做太多处理(我没试过,但我怀疑 Gas 不会接受 UTF-16 或 UTF-32 源文件)。

第十四章详细介绍了 Unicode 格式及其实现。

2.18 机器代码

Gas 将人类可读的源文件转换为一种特殊的二进制形式,称为机器代码。对于许多(非 RISC)CPU 来说,可以在不深入了解汇编器生成的底层机器代码的情况下使用汇编语言。然而,对于 RISC 处理器,如 ARM,必须对底层机器代码有基本的理解,才能理解如何编写合适的汇编语言源代码。

像大多数 RISC CPU 一样,ARM64 将单个机器指令转换为一个 32 位值。这是 RISC 背后的基本原则之一:在给定的 CPU 上,指令始终是相同的长度,而这个长度几乎总是 32 位。变长指令是禁止的。然而,如果指令集支持立即数(汇编程序将其作为机器指令的一部分编码),并且你有 64 位寄存器,当指令限制为 32 位时,如何将 64 位立即数加载到寄存器中呢?简短的答案是:“你不能。”你可能还记得在第一章中提到的,立即数的范围非常小,现在你知道为什么了:这些常数必须编码到 32 位指令值中,并与大量其他信息一起编码。这严重限制了立即数的大小。

立即数并不是你在指令的 32 位值中必须编码的唯一内容。每个指令操作数都需要一定数量的位来编码。例如,ARM64 CPU 有 32 个通用寄存器,编码 32 个值需要 5 位。因此,每个操作数中的寄存器将占用 32 位指令中可用的 5 位。以下的加法指令将至少需要 15 位来编码三个寄存器(因为任何通用寄存器都可以作为目标寄存器、第一个源寄存器和第二个源寄存器):

adds x0, x1, x2

除了寄存器和常数,ARM 指令中还必须编码其他信息,例如操作的大小(32 位与 64 位)。许多指令,如前面的加法指令,除了寄存器外,还允许立即数作为第二源操作数。必须有某种方式来区分这两种操作数形式,它们至少需要 1 位。许多指令还提供一个选项,在指令执行结束时更新标志,这又需要 1 位。还有许多额外的选项,本书尚未涉及。我们很快就会用尽可用的位数。

RISC 指令不仅必须是固定长度的,而且必须易于硬件解码。这意味着,对于所有指令,32 位指令中固定位置的若干位决定了指令的类型或分类。请参见图 2-22,这是 ARM64 的基本指令格式。

图 2-22:基本的 ARM 指令格式

op0 字段(op0 是操作码 0的缩写,通常缩写为opcode)指定指令的操作。在这个例子中,4 位字段将指令集分为七个组件,如表 2-14 所示。

表 2-14:指令编码中 op0 4 位字段

op0 编码组或指令页面

| 0000 0001 |

0010

0011 | 保留/未分配 |

1000 1001 带立即常数的数据处理指令
1010 1011 分支、异常生成指令和系统指令

| 0100 0110

1100

1110 | 加载与存储 |

0101 1101 带寄存器的数据处理指令
0111 数据处理:SIMD 和浮点指令
1111 数据处理:SIMD 和浮点指令

考虑 表 2-14 中第二组的指令:带立即常数的数据处理指令。该组使用 图 2-23 中所示的解码。

图 2-23:带立即常数的数据处理指令的编码

op1 中的 3 位(注意位 25 与 op0 共享)可以解码为 表 2-15 中所示。

表 2-15:op0 等于 0b100 的指令

op1 解码组或指令页面
000 001 PC 相对寻址模式指令
010 011 加法/减法立即数指令
100 逻辑立即数指令
101 移动宽立即数指令
110 位域指令
111 提取指令

现在考虑来自 表 2-15 的加法/减法立即数指令组。这些指令的完整编码显示在 图 2-24 中。

图 2-24:加法/减法立即数指令

加法和减法指令是一个典型的打包数据字段示例(如在第 2.12 节“位域和打包数据”中讨论,第 85 页)。这些字段具有以下含义:

sf    表示指令的大小(变种)。如果为 0,则为 32 位指令,Reg[src1] 和 Reg[dest] 字段指定的是 32 位寄存器;如果为 1,则为 64 位指令,寄存器为 64 位寄存器。

op (bit 30)    是操作码(位 24 到 28)的扩展。如果该位为 0,则指令为 add/adds 指令;如果该位为 1,则为 sub/subs 指令。

S    指定指令是否有 s 后缀(例如,adds)。如果该位为 1,则指令执行后会更新条件代码标志;如果该位为 0,则不会进行更新。

Shift    指定指令如何处理 Immediate12 字段。我将稍后更详细地讨论此字段。

Immediate12    是一个 12 位无符号整数值(0 到 +4,096)。此指令会将该值零扩展到指令的大小(32 位或 64 位)。

Reg****[src]    指定源寄存器,即指令的第二个操作数。

Reg****[dest]    指定目标寄存器,即指令的第一个操作数。

Shift 字段依赖于 Immediate12 字段,且稍微复杂。该字段可能包含 0b00 或 0b01(0b10 和 0b11 是保留值)。如果该字段包含 0b00,指令使用 Immediate12 字段的零扩展值;然而,如果该字段包含 0b01,指令首先将 Immediate12 左移 12 位,然后使用该移位后的值。这种移位形式在进行指针运算和添加页面偏移时非常有用(请参见 第三章 以了解内存管理页面的解释)。

如果加法和减法指令仅限于 12 位立即数(可能左移 12 位),那么如何将 32 位或 64 位常量加到寄存器中呢?你不能直接这么做;相反,你必须先将常量加载到另一个寄存器中,然后使用该寄存器作为第二源操作数,而不是立即数常量。正如我之前指出的,mov 指令和立即数常量也存在同样的问题。与加法和减法一样,mov 指令仅限于 32 位,这意味着你不能通过单个 mov 指令将 32 位或 64 位常量加载到寄存器中。这里的关键字是 单个。你可以通过多条 mov 指令将 32 位或 64 位常量加载到寄存器中。下一节将讨论如何实现这一点。

2.19 Operand2

大多数 ARM 数据处理指令(如加法和减法)需要三个操作数:一个目标操作数和两个源操作数。在下面的指令中,X0 是目标操作数,X1 是第一个源操作数,X2 是第二个源操作数:

add  x0, x1, x2  // Computes X0 = X1 + X2

到目前为止,我在本书中使用寄存器和立即数常量作为第二源操作数。然而,ARM CPU 支持几种格式的第二操作数,这些格式被称为 Operand2。这些格式如 表 2-16 所示,功能非常强大,使得 Operand2 在 ARM 上成为传奇。

表 2-16:Operand2 允许的字段

Operand2 描述
#immediate 一个 12 位的立即数,范围为 0–4,095(用于算术指令),或者一个 16 位的立即数(用于移动指令)。
#pattern 一个常量,用于指定 0 和 1 的排列。用于生成逻辑指令的位掩码。仅用于逻辑指令。
Wn 或 Xn 其中一个通用寄存器(32 位或 64 位)。
Wn shiftOp #imm 32 位寄存器的内容按 #imm 操作数指定的位置数进行移位(0–31)。shiftOp 可以是 lsl、lsr、asr 或 ror。
Xn shiftOp #imm 将 64 位寄存器的内容按 #imm 操作数指定的位置数进行移位(0–63)。
Wn extendOp #imm 32 位寄存器的内容经过零扩展或符号扩展,然后左移由立即数指定的位数(0–31)。此形式不能用于逻辑指令,因为符号扩展不适用于逻辑操作。extendOp 可以是 uxtb、uxth、uxtw、uxtx、sxtb、sxth、sxtw 或 sxtx。
Xn extendOp #imm 64 位寄存器的内容经过零扩展或符号扩展后,再通过立即数值(0–31)向左移动。此形式不适用于逻辑指令,因为符号扩展不适用于这些指令。

以下各节描述了这些 Operand2 形式的详细信息。

2.19.1 #immediate

Operand2 的立即数形式,或 #immediate,是它更常见的用法之一(另一个是 32 个通用寄存器中的一个)。由于立即数操作数作为 32 位指令值的一部分进行编码,它的长度通常远小于 32 位。如你所见,算术指令只允许使用一个 12 位的无符号整数作为立即数操作数。其他指令允许不同大小的立即数操作数。例如,mov 指令允许 16 位的无符号立即数操作数。

尽管你在程序中遇到的许多立即数常量会适配到 12 位或 16 位,但有些值无法适配。如本章前面所提到的,在这种情况下,你需要加载一个更大的常量到寄存器中,并使用该寄存器中的值,而不是使用立即数常量。请参见第 2.20 节《大常量》,第 111 页了解如何处理这种情况。

2.19.2 #pattern

ARM 逻辑指令(如 and、orr 和 eor)提供一个 13 位的立即数(#pattern)字段,并将其编码到 32 位指令中。然而,这并不是一个直接的 13 位立即数值,而是由 3 个独立的位字段组合而成的 位掩码模式。第十二章更详细地描述了这些位掩码的使用。在此之前,请理解,逻辑指令对立即数常量的支持有一些奇怪的限制。

Arm 编译器 Armasm 用户指南中的条目较难理解。基本上,它说明了逻辑指令的立即数常量由包含一段 1 位序列的二进制值组成,1 位序列后跟(可能前面也有)0 位序列。每个序列的长度可以是 2、4、8、16、32 或 64 位。以下是这种立即数常量的合法示例:

and     x0, x0, #0b1
and     x0, x0, #0b11
and     x0, x0, #0b111
and     x0, x0, #0b1110
and     x0, x0, #0b11100

在每种情况下,都有一个单独的 1 位序列,可能被 0 位序列包围。

以下示例不是合法的立即数常量:

and     x0, x0, #0b101
and     x0, x0, #0b10101
and     x0, x0, #0b1110111
and     x0, x0, #0b101100

这些示例是非法的,因为它们在相同的立即数常量中包含多个连续的 1 位。

“相同元素的向量”这一短语(来自 Armasm 指南)告诉我们,如果序列的长度小于寄存器大小(32 或 64 位),则指令会在寄存器中复制该序列,以填充到 32 位或 64 位。因此,如果存在多个 1 位序列且每个序列的长度是 2、4、8、16 或 32 位的倍数,那么立即数常量中可能会有多个连续的 1 位序列。以下是合法示例:

// This AND instruction contains 4 copies of the sequence
// 0b11110000:

        and     w0, w0, #0b11110000111100001111000011110000

// This sequence is legal because it contains 16 copies of
// the 2-bit sequence 0b10:

        and     w0, w0, #0b01010101010101010101010101010101

// This sequence is legal because it contains 2 copies of
// the 32-bit sequence 0b11111111111111110000000000000000:

        and     x0, x0, #0xFFFF0000FFFF0000

但是,如果你想使用“相同元素的向量”方案,你必须提供一个完全填充目标寄存器的常量。以下示例是非法的,因为它在 32 位 W0 寄存器的 HO 16 位内有两个不一致的 16 位段:

and     w0, w0, #0b1111000011110000

这种方案令人困惑,但仅用几个比特就能生成最常见的立即数类型,因此其复杂性是值得的。

如果你不小心提供了不合适的常量,Gas 会返回错误信息,例如:错误:预期兼容的寄存器或逻辑立即数,或者错误:操作数 3 中的立即数超出范围——'and w0,w0,#0b1111000011110000'。

2.19.3 寄存器

Operand2 最常见的形式是 ARM 的通用寄存器(32 位或 64 位)。鉴于到目前为止,寄存器已在大多数示例中出现,因此无需进一步讨论这种形式。

2.19.4 移位寄存器

另一个 Operand2 形式将 ARM 寄存器与移位操作结合。此形式向指令添加了一个额外的操作数,该操作数包括表 2-17 中的移位运算符之一和一个小的立即数(范围从 0 到 n,其中 n 是目标寄存器的大小)。

表 2-17: Operand2 移位运算符

运算符 描述
lsl #imm 将 Operand2 寄存器的值逻辑左移 imm 位并使用结果。
lsr #imm 将 Operand2 寄存器的值逻辑右移 imm 位并使用结果。
asr #imm 算术右移 Operand2 寄存器的值 imm 位并使用结果。
ror #imm 将 Operand2 寄存器的值逻辑右移 imm 位并使用结果。此形式仅适用于逻辑指令。

正如你在第四章中看到的,使用移位寄存器 Operand2 形式在对数组和其他数据结构进行索引时非常方便。

要使用移位寄存器 Operand2 形式,只需在指令的操作数列表末尾添加一个额外的操作数,使用表 2-17 中出现的运算符之一。以下是一些示例:

add w0, w1, w2, lsl #4  // W0 = W1 + (W2 << 4)
sub x0, x1, x2, lsr #1  // X0 = X1 - (X2 >> 1)
add x0, x1, x2, asr #1  // X0 = X1 + (X2 asr 1)
and x0, x1, x2, ror #2  // X0 = X1 & (X2 ror 2)

正如注释所示,这些指令中的每一条都会在使用 W2 或 X2 的值作为第二个源操作数之前对其进行移位。

2.19.5 扩展寄存器

最后一组 Operand2 形式提供了零扩展和符号扩展,并且可以对 Operand2 寄存器进行可选的逻辑左移。基本指令语法为:

instr  `reg`dest`, reg`src1`, reg`src2`, extendop #optional_imm`

其中 extendop 是表 2-18 中的某个运算符。如果没有提供 #optional_imm 值,则默认为 0。

表 2-18: 扩展运算符

扩展运算符 描述
uxtb #optional_imm 将 regsrc2 的低位字节零扩展到 regdest 和 regsrc1 的大小。regsrc2 操作数应为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。(Gas 似乎接受双字寄存器,并用相应的字寄存器替代。)如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。
uxth #optional_imm 将 regsrc2 的低位半字零扩展到 regdest 的大小。regsrc2 操作数应为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。
uxtw #optional_imm 将 regsrc2 的低位字零扩展到 regdest 的大小。regsrc2 操作数应为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。请注意,如果所有寄存器都是字长寄存器(Wn),则该操作符等同于 lsl #optional_imm。
uxtx #optional_imm 此操作符仅在所有寄存器为 64 位时适用。如果 Operand2 寄存器后面没有扩展(或移位)操作符,则这是默认条件。
sxtb #optional_imm 将 regsrc2 的低位字节符号扩展到 regdest 和 regsrc1 的大小。regsrc2 操作数应为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。
sxth #optional_imm 将 regsrc2 的低位半字符号扩展到 regdest 的大小。regsrc2 操作数应为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。
sxtw #optional_imm 将 regsrc2 的低位字符号扩展到 regdest 的大小。regsrc2 操作数必须为字长寄存器(Wn),无论 regdest 和 regsrc1 的大小如何。如果有可选的立即数值,则该值必须在 0 到 4 之间,并将根据指定的位数将扩展结果进行移位。如果所有寄存器都是字长寄存器(Wn),该操作符等同于 lsl #optional_imm。请注意,当所有寄存器都是字长时,推荐使用 uxtw,而不是此形式(两者在字长寄存器上执行相同的操作)。
sxtx #optional_imm 此操作符仅在所有寄存器为 64 位时适用。实际上,它与 uxtx 相同(推荐使用 uxtx 形式)。
lsl #optional_imm 如果扩展操作符是多余的(uxtx/sxtx 对于双字,uxtw/sxtw 对于字寄存器),应使用 lsl 操作符以确保清晰(它们是相同的操作)。

扩展操作符对于混合大小的算术运算非常有用。第八章 讨论了这一点,内容涉及操作不同大小的操作数。### 2.20 大常数

在本章的多个地方,已经推迟了解决无法装入 12 位或 16 位的立即常数的问题。现在是时候纠正这个遗漏了。

如前所述,如果你需要一个常数用于算术或逻辑运算,而该常数无法容纳在指令编码中为常数预留的位数中,你将不得不先将该常数加载到寄存器中,然后再对寄存器进行操作,而不是直接使用常数。这种方案的缺点是,你至少需要一条额外的指令,通常还需要更多的指令,先将常数加载到临时寄存器中,然后才能在算术操作中使用该值。例如,假设你想将 40,000 加到 X1 寄存器中。以下指令将无法工作,因为 40,000 不能装入 12 位:

add x1, x1, #40000

然而,由于 40,000 可以装入 16 位,你可以执行以下操作:

mov x0, #40000  // Works, because mov allows 16-bit consts
add x1, x1, x0  // Add 40000 to X1.

不幸的是,你的程序会变得稍微大一些(mov 指令额外需要 4 字节),也会稍微慢一些(需要执行两条指令而不是一条),但这是它能够达到的最高效率了。

如果你想添加一个不能装入 16 位的常数(比如 400,000),该怎么办?这个问题有几个解决方案。首先,正如你在第一章中看到的,ldr 指令的变体允许你将任何大小的常数加载到寄存器中(32 位或 64 位)。该指令形式具有以下语法

ldr `reg`, =`largeConstant`

其中 reg 是一个通用寄存器(32 位或 64 位),而 largeConstant 是一个立即数(字面值或符号值),它将适合指定的寄存器。该指令形式将在 .text 区段中分配存储空间(该区段是只读的),并用指定的常数初始化该存储空间。当 ldr 指令执行时,它将把该内存位置的内容加载到指定的寄存器中。

这一条指令是将大常数加载到寄存器中的一种便捷方式。然而,这种方法存在一些问题。首先,在 ARM 上访问内存是一个相对较慢的过程。其次,由于 Gas 会将常数插入到你的 .text 区段中,这可能会影响你程序中其他代码的性能;尽管这种情况很少发生,且通常不需要担心,但仍需记住这一点。

幸运的是,你可以通过其他方式将更大的常数加载到通用寄存器中。这些技术涉及 mov 指令的其他变体:movz、movk 和 mvn。

2.20.1 movz

movz 指令(移动并清零)具有以下语法

movz `reg`dest, #`imm`1
movz `reg`dest, #`imm`1, lsl #`imm`2

其中,regdest 是任何通用的(32 位或 64 位)寄存器,imm1 是一个 16 位立即数常量,imm2 是 0、16、32 或 48 四个值之一(如果 lsl #imm2 操作数未出现,则默认为 0)。

movz 指令将取 imm1 常量,并按 imm2 常量指定的位数向左移位(其他位位置填充 0,因此名称中有 清零 的含义)。然后,它会将该移位后的常量移动到目标寄存器中。以下三个指令完全相同,都会将常量 122 加载到 X0 寄存器中:

mov  x0, #122
movz x0, #122
movz x0, #122, lsl #0

mov 和 movz 的区别在于,mov 会对你提供的立即数进行符号扩展,而 movz 会进行零扩展。对于小于 0x8000 的值,两者将把相同的常量加载到目标寄存器中(实际上,汇编器可能会将 movz 指令转换为 mov,如果两者产生相同的结果)。请记住,移位值只能是 0、16、32 或 48;不能为此指令指定任意的位移值。

movz 指令在你想要将一个 16 位的值加载到 32 位寄存器的高半字 (HO half word) 或 64 位寄存器的三个高半字 (1、2 或 3) 中时非常有用。

2.20.2 movk

尽管 movz 指令允许将一些大于 65,535 的值加载到寄存器中,但它并不是加载 32 位和 64 位常量到寄存器的通用解决方案。movk 指令(与 movz 和 mov 配合使用)满足了这一需求。movk 指令(移动并保留不受影响的位)的语法与 movz 非常相似:

movk `reg`dest, #`imm`1 // Default is "lsl #0"
movk `reg`dest, #`imm`1, lsl #`imm`2

movk 指令将立即数操作数按 0、16、32 或 48 位进行移位,然后将该值合并到目标寄存器中。(它不会将其他位清零,而是保留它们的原始值。)

要将一个 32 位立即数加载到 W0 寄存器中,请使用以下指令序列:

mov  w0, #LO_16_bits
movk w0, #HO_16_bits, lsl #16

要将完整的 64 位值加载到 X0 寄存器中,请使用以下指令:

mov  x0, #LO_16_bits
movk x0, #Bits_16_to_31, lsl #16
movk x0, #Bits_32_to_47, lsl #32
movk x0, #HO_16_bits, lsl #48

大多数情况下,立即数不需要完整的 64 位,因此你可能只需要两条或三条指令,而不是完整的四条。然而,加载 64 位常量到寄存器中永远不需要超过四条指令(加载 32 位常量时永远不需要超过两条指令)。

2.20.3 movn

movn(取反移动)指令是 mov 的另一种变体,它在将立即数加载到目标寄存器之前,会对其进行逻辑取反。语法与 movz 相同(当然是将 movz 换成 movn):

movn `reg`dest, #`imm`1    // Default shift is lsl #0.
movn `reg`dest, #`imm`1, lsl #`imm`2

movn 指令将立即数按 0、16、32 或 48 位进行移位,然后将整个(32 位或 64 位)位串取反,再将其赋值给目标寄存器。

考虑以下示例:

movn x1, #0xff, lsl 16

该指令将 0xFFFFFFFFFF00FFFF 加载到 X1 寄存器中。(0xFF 向左移位 16 位,然后取反所有位。)

特别是在将负常量加载到寄存器时,movn 指令可以帮助减少加载 64 位常量所需的指令数。然而,32 位常量如果不能放入 16 位,通常需要两条指令才能完成加载。与 mvn 指令不同,它允许移位立即数常量。

2.21 继续前进

本章涵盖了基本数据类型、表示以及这些数据类型上的操作。包括十进制、二进制和十六进制计数系统,以及机器级数据,如位、字节等。它讨论了位和位串的逻辑操作、有符号和无符号整数表示,以及符号扩展和零扩展,扩展了数字所使用的位数,此外还有符号收缩和饱和操作,用于减少数字所使用的位数。它还介绍了浮点数、BCD 数据格式以及字符数据(包括 ASCII 和 Unicode 字符)。

本章还包括了机器指令编码的信息,并介绍了 ARM 汇编语言指令,用于加载和存储内存值,比较和分支指令用于控制程序流,以及移位和旋转指令。它描述了如何将数据打包成位字段,常量和其他操作数的 Operand2 格式,以及如何将不能放入 32 位指令编码中的大常量加载到寄存器中。

简而言之,本章提供了处理汇编语言程序中各种类型常量的工具和技术。虽然常量是任何汇编语言程序的重要组成部分,但能够操作变量数据是大多数计算机系统的基础。下一章将讨论 ARM 内存子系统以及如何创建和高效使用基于内存的变量。

2.22 更多信息

第三章:3 内存访问与组织

第一章和第二章向你展示了如何在汇编语言程序中声明和访问简单变量。本章将全面解释 ARM 内存访问。你将学习如何高效组织变量声明,以加快对其数据的访问。你还将了解 ARM 堆栈以及如何在堆栈上操作数据。

本章讨论了几个重要概念,包括以下内容:

  • 内存组织

  • 内存访问与内存管理单元

  • 独立位置的可执行文件与地址空间布局随机化

  • 变量存储与数据对齐

  • 字节序(内存字节顺序)

  • ARM 内存寻址模式与地址表达式

  • 堆栈操作、返回地址与保存寄存器数据

本章将教你如何高效利用计算机的内存资源。

3.1 运行时内存组织

运行中的程序以多种方式使用内存,具体取决于数据的类型。以下是你在汇编语言程序中会遇到的一些常见数据分类:

代码    编码机器指令的内存值(在 Linux 和 macOS 中也称为文本段)。

未初始化的静态数据    程序为未初始化变量预留的内存区域,这些变量在程序运行期间始终存在;操作系统在将程序加载到内存时会将该存储区域初始化为 0。

初始化的静态数据    一个在程序运行期间始终存在的内存区域。然而,操作系统会从程序的可执行文件中加载该区域内所有变量的值,因此在程序首次执行时,这些变量具有初始值。

只读数据    与初始化的静态数据类似,操作系统从可执行文件中加载此内存段的初始数据。然而,此段被标记为只读,以防止数据被意外修改。程序通常将常量和其他不变的数据放入此段(代码段也由操作系统标记为只读)。

    此特殊的内存区域用于存放动态分配的存储。诸如 C 语言的 malloc()和 free()等函数负责在堆区分配和释放存储空间。第 4.4.4 节“指针变量与动态内存分配”在第 178 页中详细讨论了动态存储分配。

堆栈    在此特殊内存区域中,程序维护过程和函数的局部变量、程序状态信息及其他临时数据。有关堆栈部分的更多信息,请参见第 3.9 节“推入与弹出操作”,见第 155 页。

这些是你在常见程序中会找到的典型区域,无论是汇编语言程序还是其他语言的程序。较小的程序可能不会使用所有这些区域,但大多数程序至少会有代码区、栈区和数据区。复杂的程序可能会为自己的目的创建额外的内存区域。某些程序可能会将这些区域合并。例如,许多程序会将代码区和只读数据区合并到内存中的同一区域(因为这两个区域中的数据都被标记为只读)。有些程序会将已初始化和未初始化的数据区合并,将未初始化的变量初始化为 0。合并区域通常由链接器程序处理。有关 GNU 链接器的更多信息,请参见第 3.12 节“更多信息”,第 167 页。

Linux 和 macOS 倾向于将不同类型的数据放入内存的不同区域(或称为)。虽然通过运行链接器并指定各种参数,可以重新配置内存,以满足自己的选择,但一种典型的组织方式可能类似于图 3-1 中的内容。

图 3-1:Linux/macOS 示例运行时内存组织

这张图仅仅是一个示例。实际的程序可能会以不同的方式组织内存,尤其是在使用地址空间布局随机化时,稍后在本章中会讨论这一点。

操作系统保留了最低的内存地址。通常情况下,你的应用程序无法访问这些低地址中的数据(或执行指令)。操作系统保留这些空间的一个原因是为了帮助捕捉 NULL 指针引用:如果你尝试访问内存地址 0x0(NULL),操作系统会生成段错误(也称为一般保护错误),意味着你访问了一个不包含有效数据的内存位置。

内存映射中的其余六个区域包含与程序相关的不同类型的数据。这些内存区域包括栈区、堆区、.text(代码)区、.data 区、.rodata(只读数据)区和.bss(未初始化数据)区。每个内存区域对应着你在 Gas 程序中可以创建的某种数据类型。接下来我将详细描述.text、.data、.rodata 和.bss 区域。(操作系统提供栈区和堆区;你通常在汇编语言程序中不会声明这两个区域,所以这里不再讨论它们。)

3.1.1 . text 区

.text 区包含在 Gas 程序中出现的机器指令。Gas 会将你编写的每一条机器指令转换为一个或多个字值的序列。在程序执行过程中,CPU 会将这些 32 位的字值解释为机器指令。

默认情况下,当 GCC/Gas/ld 链接你的程序时,它会告诉系统,程序可以从代码段执行指令和读取数据,但不能向代码段写入数据。如果你尝试将任何数据存储到代码段中,操作系统将生成段错误。

3.1.2 .data 节段

你通常会将变量放入 .data 节段中。除了声明静态变量外,你还可以将数据列表嵌入到 .data 声明节段中。你使用与将数据嵌入到 .text 节段中相同的技术来将数据嵌入到 .data 节段中:使用 .byte、.hword、.word、.dword 等指令。考虑以下示例:

 .data 
bb:  .byte    0 
     .byte    1,2,3 

u:   .word    1 
     .dword   5,2,10 

c:   .byte    0 
     .byte    'a', 'b', 'c', 'd', 'e', 'f' 

bn:  .byte    0 
     .byte    true  // Assumes true is defined as 1 

Gas 使用这些指令将值放入 .data 内存段时,会在前面的变量之后写入该段。例如,字节值 1、2、3 会在 bb 的 0 字节后写入 .data 节段。由于这些值没有关联标签,你无法在程序中对这些值进行符号访问。你可以使用索引寻址模式(本章稍后会介绍)来访问这些额外的值。

3.1.3 只读数据节段

Gas 没有提供单独的指令来创建存放只读常量的节段。然而,你可以轻松地使用 Gas 的 .section 指令创建一个通用的只读常量节段,如下所示:

.section .rodata, ""

大多数程序按照惯例使用 .rodata 标识符来表示只读数据。例如,GCC 使用该名称来表示只读常量节段。你可以在这里使用任何你选择的标识符。例如,我通常使用 .const 作为常量节段的名称。然而,由于 GCC 使用 .rodata,我将在本书中遵循这一惯例。稍后我会更多地讲解 .section 指令;目前,请注意,只要第二个参数是空字符串,Gas 就会通过这个指令创建一个只读数据节段。

.section .rodata 节段存放常量、表格和程序在执行期间无法更改的其他数据。这个节段与 .data 节段类似,但有两个区别:

  • .rodata 节段是通过 .section .rodata, "" 定义的,而不是 .data。

  • 系统不允许你在程序运行时向 .rodata 对象中的变量写入数据。

这是一个例子:

 .section  .rodata, ""
pi:      .single   3.141592653589793 // (rounded) 
e:       .single   2.718281828459045 // (rounded) 
MaxU16:  .hword    65535 
MaxI16:  .hword    32767 

在许多情况下,你可以将 .rodata 对象视为字面常量。然而,因为它们实际上是内存对象,它们的行为类似于只读 .data 对象。你不能在字面常量允许出现的地方使用 .rodata 对象。例如,你不能在寻址模式中将它们用作 位移(相对于基指针的常量 偏移量)(参见第 3.6 节,“ARM 内存寻址模式”,第 140 页),在常量表达式中,或者作为立即数。在实践中,你可以在任何允许读取 .data 变量的地方使用它们。

与 .data 段一样,你可以通过使用 .byte、.hword、.word、.dword 等数据声明,在 .rodata 段中嵌入数据值。例如:

 .section  .rodata, ""
roArray: .byte     0 
         .byte     1, 2, 3, 4, 5 
dwVal:   .dword    1 
         .dword    0 

你也可以在 .text 段中声明常量值。你在该段中声明的数据值也是只读对象,因为 Linux 和 macOS 会对 .text 段进行写保护。如果你确实将常量声明放在 .text 段中,请注意将它们放置在程序不会试图将其作为代码执行的位置(例如在 b.al 或 ret 指令之后)。除非你使用数据声明来手动编码 ARM 机器指令(这很少见,通常只由专家程序员执行),否则你不希望程序试图将数据当作机器指令执行;结果通常是未定义的。

注意

从技术上讲,在 .text 段中执行数据的结果是明确定义的:机器将解码你放入内存中的任何位模式作为机器指令。然而,很少有人能查看一段数据并将其解释为机器指令。

3.1.4 .bss 段

.data 段要求你初始化对象,即使你只是将默认值 0 放入操作数字段中。 .bss(块由符号开始)段允许你声明在程序开始运行时始终未初始化的变量。该段以 .bss 保留字开始,并包含变量声明,其初始化器必须始终为 0。以下是一个例子:

 .bss 
UninitUns32: .word  0 
i:           .word  0 
character:   .byte  0 
bb:          .byte  0 

操作系统会在将程序加载到内存时将所有 .bss 对象初始化为 0。然而,依赖这种隐式初始化可能不是一个好主意。如果你需要一个初始化为 0 的对象,请在 .data 段中声明它,并显式地将其设置为 0。

令人烦恼的是,Gas 要求在声明 .bss 段中的变量时显式提供一个初始化值为 0。优秀的汇编语言程序员不喜欢这样做,因为为他们的源代码提供一个显式值会告诉读者,他们期望该变量在程序运行时包含该值。如果程序显式地不期望该变量被初始化,那么最好告诉读者这一点。

一种非常古老的惯例是使用表达式 .-. 作为这种声明操作数字段的表示。例如:

 .bss 
UninitUns32: .word  .-. 
i:           .word  .-. 
character:   .byte  .-. 
bb:          .byte  .-. 

Gas 用当前位置计数器的当前值(参见第 3.2 节,“变量的 Gas 存储分配”,第 131 页)替代了句点 (.)。表达式 location_counter 减去 location_counter 等于 0,这符合 .bss 段中初始化器的 Gas 要求。这种奇怪的语法让读者知道,你并不期望变量在程序运行时显式地初始化为 0。

如果 .-. 对你来说太奇怪(或者你不想输入三个字符),我通常会使用类似这样的表达式来获得相同的结果:

 .equ   _, 0  // "_" is a legitimate identifier 
             .bss 
UninitUns32: .word  _
i:           .word  _
character:   .byte  _
bb:          .byte  _

本书倾向于使用 .-. 形式(当没有明确指定 0 时),因为这种形式具有历史先例。然而,这种形式有一个缺点:它不适用于 .qword 声明(这是 Gas 的一个限制)。

你在 .bss 部分声明的变量可能会在程序的可执行文件中占用更少的磁盘空间。这是因为 Gas 会将 .rodata 和 .data 对象的初始值写入可执行文件,但它可能会对你在 .bss 部分声明的未初始化变量使用紧凑的表示方法。请注意,这种行为依赖于操作系统版本和目标模块格式。

3.1.5 .section 指令

.section 指令允许你使用任何你喜欢的名称创建部分(.rodata 部分就是一个例子)。此指令的语法是:

.section `identifier`, `flags` 

其中 identifier 是任何合法的 Gas 标识符(不必以句点开头),flags 是一个用引号括起来的字符串。字符串的内容根据操作系统的不同而有所变化,但 Linux 和 macOS 都支持以下字符:

b    部分是一个 .bss 部分,将保存未初始化的数据。所有数据声明必须具有 0 初始值。

x    部分包含可执行代码。

w    部分包含可写数据。

a    部分是可分配的(数据部分必须存在)。

d    部分是数据部分。

flags 字符串可以包含零个或多个这些字符,尽管某些标志(如 "b" 和 "x" 或 "d")是互斥的。如果字符串中没有 "w" 标志,该部分将是只读的。以下是一些典型的 .section 声明:

.section aDataSection, "adw" // Typical data section 
.section .const, ""          // Like .rodata 
.section .code, "x"          // Code section (like .text) 

每个你定义的独特部分将分配给自己的内存块(如 图 3-1 中所示的块)。GNU 链接器/加载器将在将它们分配到内存块时合并所有相同名称的部分。

3.1.6 声明部分

.data、.rodata、.bss、.text 和其他命名部分在你的程序中可能出现零次或多次。声明部分可以按任何顺序出现,正如下面的示例所示:

 .data
i_static:   .word     0

            .bss
i_uninit:   .word     .-.

 .section  .rodata, ""
i_readonly: .word     5

            .data
j:          .word     0

            .section  .rodata, ""
i2:         .word     9

            .bss
c:          .byte     .-.

            .bss
d:          .word     .-.

            .text

`Code goes here.`

这些部分可以按任意顺序出现,且给定的声明部分可以在程序中出现多次。如前所述,当多个相同类型的声明部分(例如前面示例中的三个 .bss 部分)出现在程序的声明部分时,Gas 会将它们合并为一个组,以任何它喜欢的顺序。

3.1.7 内存访问和 MMU 页

ARM 的 内存管理单元(MMU) 将内存划分为称为 的块。操作系统负责管理内存中的页,因此应用程序通常不需要关心页的组织。然而,在处理内存中的页时,确保你了解 CPU 是否允许访问给定的内存位置,以及该位置是可读/写还是只读(写保护)。

每个程序部分在内存中都出现在连续的 MMU 页面中。也就是说,.rodata 部分从 MMU 页面中的偏移量 0 开始,并依次消耗该部分中所有数据所占用的内存页面。内存中的下一个部分(可能是 .data)从紧跟在上一个部分的最后一页后的下一个 MMU 页面的偏移量 0 开始。如果上一个部分(例如 .rodata)没有消耗 4,096 字节的整数倍,则该部分数据的末尾与最后一页的末尾之间会有填充空间,以确保下一个部分从 MMU 页面边界开始。

每个新的部分都会在它自己的 MMU 页面中开始,因为 MMU 通过使用页面粒度来控制对内存的访问。例如,MMU 控制内存中的页面是可读/可写还是只读。对于 .rodata 部分,你希望内存是只读的。对于 .data 部分,你希望允许读取和写入。因为 MMU 只能按页面逐页强制执行这些属性,所以你不能将 .data 部分的信息与 .rodata 部分放在同一个 MMU 页面中。

通常,所有这些对你的代码都是完全透明的。你在 .data(或 .bss)部分声明的数据是可读且可写的,而 .rodata 或 .text 部分的数据是只读的(.text 部分也可以执行)。除了将数据放在特定的部分之外,你无需过多担心页面属性。

在一种情况下,你确实需要关注内存中的 MMU 页面组织。有时,访问(读取)内存中数据结构末尾之外的数据是方便的。然而,如果该数据结构与 MMU 页面的末尾对齐,访问内存中的下一页可能会遇到问题。内存中的某些页面是不可访问的;MMU 不允许对该页面进行读取、写入或执行操作。尝试这样做将产生 ARM 段错误。这通常会崩溃你的程序,除非你已经设置了异常处理程序来处理段错误。如果你有一次数据访问跨越了页面边界,并且内存中的下一页不可访问,那么这将崩溃你的程序。例如,考虑对 MMU 页面末尾的字节对象进行半字访问,如 图 3-2 所示。

图 3-2:内存管理页末尾的半字访问

一般来说,你不应该读取数据结构末尾之外的数据。如果出于某种原因你必须这么做,确保访问内存中下一页是合法的。不言而喻,你永远不应写入数据结构末尾之外的数据;这样做总是错误的,可能带来比仅仅让程序崩溃更严重的问题(包括严重的安全问题)。

3.1.8 PIE 和 ASLR

如第一章中所述,macOS 强制所有代码使用位置无关的可执行文件(PIE)格式。Linux 并不绝对要求这样做,但如果你选择,可以编写 PIE 代码。PIE 代码有两个主要原因:共享库和安全性,这些在《Linux 与 macOS:位置无关的可执行文件》一文中已在第 23 页中详细讲解。然而,由于 PIE 代码的行为深刻影响了你编写 ARM 汇编语言的方式,因此值得花些时间进一步讨论 PIE,尤其是地址空间布局随机化(ASLR)

ASLR 是操作系统的一种尝试,旨在阻止各种试图搞清楚代码和数据在应用程序中位置的攻击(黑客)。在 PIE 和 ASLR 之前,大多数操作系统总是将可执行代码和数据加载到内存中的相同地址,这使得黑客容易修补或以其他方式干扰可执行程序。通过将代码和数据段加载到随机内存位置,PIE/ASLR 使得攻击者更难接触到正在执行的代码。

由于 ASLR,正在执行的程序在内存中的布局实际上不会像图 3-1 所示那样。对于某个程序执行实例,它可能类似于图 3-3 所示。

图 3-3:应用程序执行的一种可能的内存布局

然而,在程序的下一次运行中,代码段可能会被重新排列,并放置到内存中的不同位置。

虽然 PIE/ASLR 使得黑客更难利用你的代码,但它也对 ARM 的指令集产生了很大影响。考虑以下(合法的)ARM ldr 指令:

ldr w0, someWordVar  // Assume someWordVar is in .data 

xs

这通常会从位于.data 段中的 32 位变量 someWordVar 加载 W0 寄存器。该指令使用PC 相对寻址模式,这意味着该指令编码了从 ldr 指令的地址到内存中 someWordVar 变量的偏移量。然而,如果你在 macOS 下汇编这个程序,你会得到以下错误:

error: unknown AArch64 fixup kind! 

在 Linux 下(Ubuntu 和 Raspberry Pi OS 似乎有所不同;你的使用情况可能有所不同),你会看到类似以下内容:

relocation truncated to fit: R_AARCH64_LD_PREL_LO19 against `.data' 

这是一个真实的 ARM64 指令,应该可以工作。事实上

ldr reg, =constant 

这只是该指令的一种特殊形式,而且它确实有效。

这个问题源于 ARM 32 位指令长度。如果你查阅 ARM 参考手册中的 ldr 指令编码,你会发现它为内存位置的地址预留了 19 位。这实际上是一个偏移量(以字节为单位的距离),表示相对于 ldr 指令的地址(即 19 位字段的值会加到程序计数器 PC 上,从而得到实际的内存地址)。因为它引用的数据在 .text 区段,并且 .text 区段中的所有内容都是按字对齐的,因此这个 19 位的偏移量实际上是一个字偏移,而不是字节偏移。这有效地为 ldr 指令提供了额外的 2 位(低 2 位总是为 0)。这个有效的 21 位偏移量使得 ldr 指令可以访问离 ldr 指令 ±1MB 以内的数据。

不幸的是,当访问位于 .data 区段的数据时,由于操作系统很友善地将其放置在一个随机地址(可能距离超过 1MB),ldr 指令的 21 位范围将不足以访问该数据。这就是为什么 Gas 会报错,提示尝试用 ldr 指令访问 .data 区段的变量。底线是,除非数据也在 .text 区段并且与 ldr 指令的距离不超过 ±1MB,否则不能直接使用该指令来访问数据。

3.1.9 .pool 区段

.pool 区段是你程序中的一个 Gas 伪区段。如前所述,以下指令通过将常量放置在内存中的某个位置,然后将该内存位置的内容加载到目标寄存器中,来将一个大常量加载到寄存器中:

ldr reg, =largeConstant 

换句话说,这个指令完全等价于以下两种情况中的任意一种:

 ldr x0, a64_bit_constant 
    ldr w0, a32_bit_constant 
     . 
     . 
     . 
// Somewhere in the .text section that will never 
// be executed as code: 

a64_bit_constant: .dword  `The_Actual_64bit_Constant_Value` 
a32_bit_constant: .word   `The_Actual_32bit_Constant_Value` 

Gas 会自动找出合适的地方来放置这些常量:将它们放在引用这些常量的指令附近,但不在代码路径中。

如果你希望控制这些常量在 .text 区段中的位置,可以使用 .pool 指令。无论你将此指令放置在 .text 区段中的何处(并且必须放在 .text 区段内),Gas 都会生成它所产生的常量。只要确保,如果你在代码中放置 .pool 指令,应该把它放在一个无条件跳转或返回指令之后,这样程序流就不会试图将这些数据作为机器指令执行。

通常,你不需要在源代码中放置 .pool 指令,因为 Gas 会合理地找到一个合适的位置来放置数据。然而,如果你打算在 .text 区段中插入你自己的数据,你可能需要插入 .pool 指令,并将数据声明紧跟其后。请注意,.pool 后的数据是 .text 区段的一部分,因此你可以继续在 .pool 后面放置机器指令。

3.2 Gas 变量存储分配

Gas 会为每个声明节(.text、.data、.rodata、.bss 以及任何其他命名节)关联一个当前的位置计数器。这些位置计数器初始值为 0。每当你在这些节中声明一个变量(或在代码节中编写代码时),Gas 会将该节的当前位置计数器的当前值与标签关联,并将该位置计数器的值增加你正在声明的对象的大小。

例如,假设以下是程序中唯一的 .data 声明节:

 .data
bb: .byte  0        // Location counter = 0, size = 1
s:  .hword 0        // Location counter = 1, size = 2
w:  .word  0        // Location counter = 3, size = 4
d:  .dword 0        // Location counter = 7, size = 8
q:  .qword 0        // Location counter = 15, size = 16
                    // Location counter is now 31.

在同一个 .data 节中列出的变量声明具有连续的偏移量(位置计数器值)。根据前面的声明,s 会紧跟在内存中的 bb 后面,w 会紧跟在 s 后面,d 会紧跟在 w 后面,以此类推。这些偏移量并不是变量的实际运行时地址。在运行时,系统会将每个节加载到内存中的基址。链接器和操作系统将内存节的基址加到这些位置计数器值(通常称为位移偏移量)上,从而生成变量的实际内存地址。

请记住,你可能会将其他模块与程序链接(例如,来自 C 标准库的模块)或甚至在同一源文件中添加额外的 .data 节,而链接器必须合并这些 .data 节。每个独立的节(即使它与其他节同名)都有自己的位置计数器,在为该节中的变量分配存储时,该计数器从 0 开始。因此,单个变量的偏移量可能与其最终的内存地址几乎没有关系。

Gas 在 .rodata、.data 和 .bss 节中分配的内存对象位于内存的完全不同区域。因此,你不能假设以下三个内存对象会出现在相邻的内存位置(实际上,它们很可能不会):

 .data
bb: .byte    0

    .section .rodata, ""
w:  .word    0x1234

    .bss
d:  .dword   .-.

实际上,Gas 甚至不会保证你在不同的 .data(或其他)节中声明的变量在内存中是相邻的,即使你的代码中这些声明之间没有任何内容。例如,你不能假设以下声明中的 bb、w 和 d 是否在相邻的内存位置:

 .data
bb: .byte   0

    .data
w:  .word   0x1234

    .data
d:  .dword  0

如果你的代码要求这些变量占用相邻的内存位置,你必须将它们声明在同一个 .data 节中。

3.3 小端和大端数据组织

正如你在第 1.6.2 节《内存子系统》中学到的,在第 14 页,ARM 在内存中存储多字节数据类型时,将低字节存储在内存中最低地址位置,高字节存储在最高地址位置(见图 1-6)。这种内存数据组织方式被称为小端。小端数据组织中,低字节先存储,高字节最后存储,这在许多现代 CPU 中是常见的。然而,这并不是唯一的可能方式。

大端数据组织会反转内存中字节的顺序。数据结构的高位字节首先出现在最低内存地址,低位字节则出现在最高内存地址。表 3-1 描述了半字的内存组织。

表 3-1:半字对象内存组织

数据字节 小端 大端
0 (低位字节) base + 0 base + 1
1 (高位字节) base + 1 base + 0

表 3-2 描述了字的内存组织。

表 3-2:字对象内存组织

数据字节 小端 大端
0 (低位字节) base + 0 base + 3
1 base + 1 base + 2
2 base + 2 base + 1
3 (高位字节) base + 3 base + 0

表 3-3 描述了双字的内存组织。

表 3-3:双字对象内存组织

数据字节 小端 大端
0 (低位字节) base + 0 base + 7
1 base + 1 base + 6
2 base + 2 base + 5
3 base + 3 base + 4
4 base + 4 base + 3
5 base + 5 base + 2
6 base + 6 base + 1
7 (高位字节) base + 7 base + 0

通常,你不会太关注 ARM CPU 上的大端内存组织。然而,有时你可能需要处理由不同 CPU(或协议,如传输控制协议/互联网协议,简称 TCP/IP)产生的数据,这些数据使用大端组织作为其标准整数格式。如果你将一个大端值从内存加载到 CPU 寄存器中,这个值将会不正确。

如果你有一个 16 位的大端值在内存中,并将其加载到寄存器中,它的字节将会被交换。对于 16 位的值,你可以通过使用 rev16 指令来修正这个问题,该指令的语法如下:

rev16 `reg`dest`, reg`src 

在这里,regdest 和 regsrc 是任何 32 位或 64 位的通用寄存器(两者必须相同大小)。这条指令会交换源寄存器中每个 16 位半字中的 2 个字节;也就是说,它在 32 位寄存器中作用于 hword0 和 hword1,在 64 位寄存器中作用于 hword0、hword1、hword2 和 hword3。例如:

ldr     w1, =0x12345678 
rev16   w1, w1 

将会在 W1 寄存器中产生 0x34127856,交换了字节 0 和 1 以及字节 2 和 3。

如果你有一个 32 位的值在寄存器中(无论是 32 位还是 64 位),你可以通过使用 rev32 指令交换该寄存器中的 4 个字节:

rev32 `reg`dest, `reg`src 

再次提醒,寄存器可以是 32 位或 64 位,但两者必须相同大小。在 32 位寄存器中,这将交换字节 0 和 3 以及字节 1 和 2。在 64 位寄存器中,它将交换字节 0 和 3、1 和 2、7 和 4,以及 6 和 5(参见图 3-4)。

图 3-4:rev32 指令的操作

rev 指令将会在 64 位寄存器中交换字节 7 和 0、字节 6 和 1、字节 5 和 2、字节 4 和 3(参见图 3-5)。

图 3-5:rev 指令的操作

rev 指令仅接受 64 位寄存器。

3.4 内存访问

第 1.6.2 节,“内存子系统”,在第 14 页中描述了 ARM CPU 如何通过数据总线从内存中获取数据。在理想化的 CPU 中,数据总线的大小与 CPU 上标准的整数寄存器大小相同;因此,你可以预期 ARM CPU 会有一个 64 位的数据总线。但实际上,现代 CPU 通常会将物理数据总线与主内存的连接做得更大,以提高系统性能。总线通过一次操作将大块数据从内存中提取出来,并将这些数据放入 CPU 的缓存中,缓存充当着 CPU 与物理内存之间的缓冲区。

从 CPU 的角度来看,缓存就是内存。因此,当本节的其余部分讨论内存时,通常指的是缓存中的数据。由于系统会透明地将内存访问映射到缓存中,我们可以像讨论没有缓存一样讨论内存,并根据需要讨论缓存的优势。

在 ARM 之前的早期处理器中,内存是按字节数组(8 位机器,如 Intel 8088)、半字(16 位机器,如 Intel 8086 和 80286)或字(32 位机器,如 32 位 ARM CPU)进行排列的。在 16 位机器上,地址的 LO 位在物理上不会出现在地址总线上。这意味着地址 126 和 127 在地址总线上会显示相同的位模式(126,LO 位是 0),如图 3-6 所示。

图 3-6:16 位处理器的地址总线和数据总线

在读取字节时,CPU 使用地址的 LO 位来选择数据总线上的 LO 字节或 HO 字节。图 3-7 展示了在偶数地址(图中的地址 126)访问字节时的过程。

图 3-7:在 16 位 CPU 上从偶数地址读取一个字节

图 3-8 展示了在奇数地址上读取字节的内存访问(图中地址 127)。请注意,在图 3-7 和图 3-8 中,出现在地址总线上的地址都是 126。

图 3-8:在 16 位 CPU 上从奇数地址读取一个字节

当这个 16 位 CPU 想要在奇数地址访问 16 位数据时,会发生什么情况?例如,假设在这些图中,CPU 读取地址 125 的字。当 CPU 将地址 125 放到地址总线上时,LO 位不会在物理上出现。因此,总线上实际的地址是 124。如果 CPU 此时从数据总线上读取 LO 的 8 位,它将得到地址 124 的数据,而不是地址 125 的数据。

幸运的是,CPU 足够智能,能够搞清楚发生了什么:它从数据总线的高 8 位提取数据,并将其作为数据操作数的低 8 位使用。然而,CPU 所需的高 8 位并不在数据总线上。CPU 必须发起第二次读取操作,将地址 126 放到地址总线上,以获取高 8 位(这些将位于数据总线的低 8 位,但 CPU 可以识别出来)。这个读取操作需要两个内存周期才能完成。因此,读取内存中的数据的指令将比从地址是 2 的倍数(16 位对齐)读取数据时的执行时间更长。

在 32 位处理器上也存在相同的问题,只是 32 位数据总线允许 CPU 一次读取 4 字节。在一个不是 4 的整数倍地址上读取 32 位值会产生相同的性能惩罚。然而,在奇数地址上访问 16 位操作数并不总是保证会有额外的内存周期——只有当地址除以 4 后余数为 3 时才会产生惩罚。特别是,如果你在一个地址上访问 16 位值(在 32 位总线上),该地址的低 2 位包含 0b01,CPU 可以在一个内存周期内读取该字,如图 3-9 所示。

图 3-9:在 32 位数据总线上访问一个字

现代带有缓存系统的 ARM CPU 已经基本消除了这个问题。只要数据(1、2、4 或 8 字节大小)完全位于一个缓存行内——一个由处理器定义的字节数——对未对齐访问不会产生内存周期惩罚。如果访问跨越了缓存行边界,CPU 在执行两个内存操作以获取(或存储)数据时会稍微变慢。### 3.5 气体对数据对齐的支持

要编写快速的程序,必须确保正确地对齐内存中的数据对象。正确的对齐意味着对象的起始地址是某个大小的倍数——通常是对象的大小,如果对象的大小是 2 的幂并且长度不超过 32 字节。对于大于 32 字节的对象,将对象对齐到 8、16 或 32 字节的地址边界通常就足够了。对于小于 16 字节的对象,将对象对齐到一个大于或等于对象大小的下一个 2 的幂的地址通常是可以的。

如前一节所述,访问未按适当地址对齐的数据可能需要额外的时间。因此,如果你希望确保程序运行尽可能快,你应该尝试根据数据对象的大小来对齐数据。

每当你为不同大小的对象分配存储空间并且这些对象位于相邻的内存位置时,数据就会发生错位。例如,如果你声明一个 byte 变量,它将占用 1 字节存储空间,那么你在该声明部分中声明的下一个变量的地址将是该 byte 对象的地址加 1。如果 byte 变量的地址恰好是偶数地址,则紧随其后的变量将从一个奇数地址开始。如果那个变量是 half-word、word 或 dword 对象,它的起始地址将不理想。

在这一节中,我们将探索确保变量根据其大小在适当的起始地址对齐的方法。考虑以下 Gas 变量声明:

 .data 
w:  .word  0 
bb: .byte  0 
s:  .hword 0 
w2: .word  0 
s2: .hword 0 
b2: .byte  0 
dw: .dword 0 

程序中的第一个 .data 声明将其变量放置在一个是 4,096 字节的偶数倍的地址上。无论哪个变量首先出现在该 .data 声明中,都保证会被对齐到一个合理的地址。每个后续变量的地址是所有前面变量大小之和,再加上该 .data 部分的起始地址。

因此,假设 Gas 在前面的例子中将变量分配到起始地址为 4096 的位置,它将按照以下地址分配这些变量:

 // Start Adrs       Length 
w:    .word   0      //    4096            4 
bb:   .byte   0      //    4100            1 
s:    .hword  0      //    4101            2 
w2:   .word   0      //    4103            4 
s2:   .hword  0      //    4107            2 
b2:   .byte   0      //    4109            1 
dw:   .dword  0      //    4110            8 

除了第一个变量(它在 4KB 边界上对齐)和 byte 变量(它们的对齐无关紧要)之外,所有这些变量都是错位的。s、s2 和 w2 变量从奇数地址开始,而 dw 变量虽然在一个偶数地址上对齐,但它并不是 8 的倍数(虽然对齐到 word,但没有对齐到 dword)。

保证变量正确对齐的一种简单方法是将所有的 dword 变量放在最前面,word 变量放在第二,half-word 变量放在第三,最后将 byte 变量放在声明的末尾,如下所示:

 .data 
dw:   .dword  0 
w:    .word   0 
w2:   .word   0 
s:    .hword  0 
s2:   .hword  0 
bb:   .byte   0 
b2:   .byte   0 

该组织在内存中生成以下地址:

 //  Start Adrs     Length 
dw:    .dword  0   //     4096          8 
w2:    .word   0   //     4104          4 
w3:    .word   0   //     4108          4 
s:     .hword  0   //     4112          2 
s2:    .hword  0   //     4114          2 
bb:    .byte   0   //     4116          1 
b2:    .byte   0   //     4117          1 

这些变量都对齐到了合理的地址。

不幸的是,你很少能以这种方式安排你的变量。虽然有许多技术原因使得这种对齐不可能实现,但一个实际的原因是,它不会让你按照逻辑功能来组织变量声明(也就是说,你可能希望将相关的变量放在一起,而不管它们的大小)。

为了解决这个问题,Gas 提供了 .align 和 .balign 指令。如第 1.2 节“汇编语言程序的结构”中所述,在 第 5 页,.align 参数是一个将提高到 2 的幂的值,.balign 的操作数是必须是 2 的幂(1、2、4、8、16 等)的整数。这些指令确保下一个内存对象会对齐到指定的大小。

默认情况下,这些指令会用 0 填充它们跳过的数据字节;在.text 段中,Gas 通过使用 nop(无操作)指令来对齐代码。如果你希望使用不同的填充值,这两个指令允许第二个操作数:

.align  pwr2Alignment, padValue 
.balign alignment, padValue 

在这里,padValue 必须是一个 8 位常量,指令将使用它作为填充值。Gas 还允许第三个参数,这是最大允许的填充量;更多详情请参见 Gas 文档。

之前的示例可以使用.align 指令重写,如下所示:

 .data 
     .align  2   // Align on 4-byte boundary. 
w:   .word   0 
bb:  .byte   0 
     .align  1   // Align on 2-byte boundary. 
s:   .hword  0 
     .align  2   // Align on 4-byte boundary. 
w2:  .word   0 
s2:  .hword  0 
b2:  .byte   0 
     .align  3   // Align on 8-byte boundary. 
dw:  .dword  0 

如果 Gas 判断某个.align 指令的当前地址(位置计数器值)不是指定值的整数倍,Gas 会在前一个变量声明之后静默地添加额外的填充字节,直到.data 段中的当前地址是指定值的整数倍。这会使你的数据增大几个字节,以换取更快的访问速度。由于使用此功能时数据只会稍微增大一些,这可能是一个值得的折中。

一般来说,如果你想要最快的访问速度,选择一个与要对齐对象大小相等的对齐值。也就是说,将半字对齐到偶数边界,使用.align 1 指令;将字对齐到 4 字节边界,使用.align 2;将双字对齐到 8 字节边界,使用.align 3,依此类推。如果对象的大小不是 2 的幂次方,则将其对齐到下一个较大的 2 的幂次方。

数据对齐并非总是必需的,因为现代 ARM CPU 的缓存架构能够处理大部分非对齐数据。只有当变量需要极快的访问速度时,才应使用对齐指令。

3.6 ARM 内存寻址模式

大多数情况下,ARM 使用的是非常标准的 RISC 加载/存储架构。这意味着它几乎通过使用从内存加载寄存器或将寄存器中存储的值存储到内存的指令来完成所有的内存访问。加载和存储指令通过使用内存寻址模式来访问内存,寻址模式是 CPU 用来确定内存位置地址的机制。ARM 的内存寻址模式提供了灵活的内存访问方式,使你可以轻松访问变量、数组、结构体、指针及其他复杂数据类型。掌握 ARM 的寻址模式是掌握 ARM 汇编语言的重要一步。

除了加载和存储,ARM 还使用了原子指令。大多数情况下,这些指令是加载和存储指令的变体,额外增加了一些多处理器应用所需的功能。原子指令超出了本文的讨论范围;更多信息,请参阅 ARM V8 参考手册。

到目前为止,本书只介绍了两种访问内存的机制:在第一章中介绍的寄存器间接寻址模式(例如,[X0]),以及在第 3.1.8 节 “PIE 和 ASLR”中讨论的 PC 相对寻址模式。然而,ARM 提供了超过六种(根据你的计算方式)访问内存数据的模式。以下各节将描述这些模式。

3.6.1 PC 相对地址

PC 相对寻址模式仅在从 .text 段中获取值时有用,因为其他段很可能超出了该寻址模式的±1MB 范围。因此,直接访问 .text 段中的常量数据比访问 .rodata 段(或其他只读段)中的数据要容易得多。

在 .text 段使用 PC 相对寻址模式时,会遇到一些问题。首先,由于 32 位指令编码中嵌入的 19 位偏移量被左移了 2 位来产生字的偏移量(如前所述),因此使用此寻址模式时只能加载字和双字值——不能加载字节或半字。例如,你可以使用寄存器间接寻址模式访问 .text 段中的字节和半字值,但不能使用 PC 相对寻址模式。

使用 PC 相对寻址模式访问 .text 段中的数据时,请记住以下几点:

  • 在 macOS 上,.text 段中的所有标签必须按 4 字节对齐,即使与该标签关联的数据不需要这种对齐(例如字节和半字)。

  • .text 段中的数据值不能引用其他段(例如,第四章中讨论的指针常量)。然而,这些对象可以引用 .text 段中本身的数据(这对于跳转表来说很重要,详见第七章)。

  • 数据必须位于引用它的指令的±1MB 范围内。例如,你不能创建超过 1MB 的数组数据。

  • 使用 PC 相对寻址模式时,仅允许字(word)和双字(dword)访问。

  • 由于数据位于 .text 段中,它是只读的;你不能在 .text 段中放置变量。

要使用 PC 相对寻址模式,只需引用你用于声明 .text 段中对象的标签:

 ldr w0, wordVar 
           . 
           . 
           . 
wordVar: .word 12345 

别忘了,你在 .text 段中声明的所有数据需要处于执行路径之外,最好放在 .pool 段中。(当我在第五章讨论代码流中的参数传递时,你会看到这个规则的一个例外。)

3.6.2 寄存器间接

到目前为止,本书中的大多数示例都使用了寄存器间接寻址模式。间接寻址意味着操作数不是实际地址,而是操作数的值指定了要使用的内存地址。在寄存器间接寻址模式下,寄存器中保存的值是要访问的内存位置的地址。例如,指令

ldr x0, [x1] 

告诉 CPU 从当前存储在 X1 中的地址位置加载 X0 的值。X1 周围的方括号告诉 Gas 使用寄存器间接寻址模式。

ARM 有 32 种这种寻址模式,每一种都对应 32 个通用 64 位寄存器(尽管 X31 是非法的;应使用 SP 代替)。在使用间接寻址模式时,你不能在方括号中指定 32 位寄存器。

从技术上讲,你可以将一个任意数值加载到 64 位寄存器中,然后通过使用寄存器间接寻址模式间接访问该位置:

ldr x1, =12345678 
ldr x0, [x1]    // Attempts to access location 12345678 

不幸的是(或幸运的是,取决于你如何看待它),这可能会导致操作系统产生段错误,因为并非总是合法访问任意的内存位置。正如你很快会看到的,还有更好的方法将对象的地址加载到寄存器中。

你可以使用寄存器间接寻址模式来访问指针引用的数据、遍历数组数据,以及在程序运行时任何需要修改对象地址的情况。

在使用寄存器间接寻址模式时,你通过变量的数值内存地址(即加载到寄存器中的值)来引用变量,而不是通过变量的名称。这是使用 匿名变量 的一个例子。

aoaa.inc 包含文件提供了 lea 宏,你可以使用它获取变量的地址并将其放入一个 64 位寄存器中:

lea x1, j 

执行这个 lea 指令后,你可以使用 [x1] 寄存器间接寻址模式间接访问 j 的值(这是到目前为止几乎所有示例访问内存的方式)。在第 3.8 节“获取内存对象的地址”,第 153 页 中,你将看到 lea 宏是如何工作的。

3.6.3 间接加偏移

考虑以下数据声明,它类似于本书中给出的其他示例:

bVar:  .byte 0, 1, 2, 3 

如果你将 X1 加载为 bVar 的地址,你可以使用如下指令访问该字节(0):

ldrb w1, [x1]   // Load byte at bVar (0) into W1\. 

要访问内存中该 0 后面的其他 3 个字节,你可以使用 间接加偏移 寻址模式。以下是该模式的语法:

[X`n`|SP, #`signed_expression`] 

Xn|SP 表示 X0 到 X30 或 SP,signed_expression 是一个小整数表达式,范围为 –256 到 +255。这个特定的寻址模式将计算 Xnn = 0 到 30,或 SP)中地址与有符号常量的和,并将其作为 有效内存地址(即要访问的内存地址)使用。

例如,如果 X1 包含前一个示例中的 bVar 的地址,以下指令将获取 bVar 之后的字节(即该示例中包含 1 的字节):

ldrb  w0, [x1, #1] // Fetch byte at address X1 + 1\. 

再次提醒,32 位指令大小严重限制了该寻址模式的范围(只有 9 位可用于有符号偏移量)。如果需要更大的偏移量,必须显式地将值添加到 X1 中的地址中(如果需要保持 X1 中的基地址,可能需要使用不同的寄存器)。例如,以下代码通过使用 X2 来保存有效地址来实现这一点:

add  x2, x1, #2000  // Access location X1 + 2000\. 
ldrb w2, [x2] 

这将计算出 X2 = X1 + 2000,并将该地址处的字加载到 W2 中。

3.6.4 带缩放的间接加偏移

带缩放的间接加偏移寻址模式是间接加偏移模式的一种稍微复杂的变体。它将一个 12 位的无符号常数嵌入到指令编码中,并根据数据传输的大小将该常数缩放(乘以)1、2、4 或 8。这为间接加偏移模式的 9 位有符号偏移量提供了范围扩展。

该寻址模式与间接加偏移寻址模式使用相同的语法,只是它不允许有符号偏移量:

[X`n`|SP, #`unsigned_expression`] 

对于字节传输(ldrb),无符号表达式可以在 0 到 0xFFF(4,095)范围内。对于半字传输(ldrh),无符号表达式可以在 0 到 0x1FFE 范围内,但偏移量必须是偶数。对于字传输(ldr),无符号表达式必须在 0 到 0x3FFC 范围内,并且必须是 4 的倍数。对于双字传输,无符号表达式必须在 0 到 0x7FF8 范围内,并且必须是 8 的倍数。正如你将在第四章中看到的,这些数字非常适合访问字节、半字、字或双字数组的元素。

通常,汇编器会根据寻址模式中偏移量的值自动选择间接加偏移和带缩放的间接加偏移寻址模式。有时,这个选择可能会有歧义。例如:

ldr  w0, [X2, #16] 

在这里,汇编器可以选择带缩放或不带缩放的寻址模式。通常,它会选择带缩放的形式。它的选择不应影响你的代码;无论选择哪种形式,都会将相应的内存单词加载到 W0 寄存器中。

如果出于某种原因,你希望显式指定不带缩放的寻址模式,可以使用 ldur 和 stur 指令(加载或存储不带缩放的寄存器)。

3.6.5 预索引

预索引寻址模式与间接加偏移寻址模式非常相似,因为它结合了一个 64 位寄存器和一个有符号的 9 位偏移量。然而,这种寻址模式会在访问内存之前将寄存器和偏移量的和复制到寄存器中。最终,它访问的地址与间接加偏移模式相同,但一旦指令完成,索引寄存器将指向内存中的索引位置。该模式在通过循环递增寄存器访问数组和其他数据结构时非常有用。

预索引寻址模式的语法如下:

[X`n`|SP, #`signed_expression`]!  // X`n`|SP has the usual meaning. 

该序列末尾的!符号区分了预索引寻址模式。与间接加偏移模式类似,signed_expression 值是有限制的——在这种情况下,限制为 9 位(–256 到+255)。

以下代码片段使用了该寻址模式:

bVar:  .byte 0, 1, 2, 3 
         . 
         . 
         . 
        lea  x0, bVar-1  // Initialize with adrs of bVar – 1\. 
        mov  x1, 4 
loop:   ldrb w2, [x0, #1]! 

 `Do something with the byte in W2.` 

        subs x1, x1, #1 
        bne  loop 

在该循环的第一次迭代中,寻址模式将 1 加到 X0,使其指向 bVar 数组中第一个字节(4 字节数组)。这也使得 X0 指向该第一个字节。在每次成功的循环迭代中,X0 递增 1,访问 bVar 数组中的下一个字节。

subs 指令将在 X1 递减到 0 时设置 Z 标志。当发生这种情况时,bne(如果 Z = 0 则分支)指令将跳过,终止循环。

3.6.6 后索引

后索引寻址模式与预索引寻址模式非常相似,只是它在用有符号的立即数值更新寄存器之前,使用寄存器的值作为内存地址。后索引寻址模式的语法如下:

[X`n`|SP], #`signed_expression` // X`n`|SP has the usual meaning. 

同样,signed_expression 的值限制为 9 位(–256 到+255)。

上一节中的示例可以通过使用后索引寻址模式进行改写并稍作改进:

bVar:  .byte 0, 1, 2, 3 
         . 
         . 
         . 
        lea  x0, bVar 
        mov  x1, 4 
loop:   ldrb w2, [x0], #1 

 `Do something with the byte in W2.` 

        subs x1, x1, #1 
        bne  loop 

这个例子开始时 X0 指向 bVar,并以 X0 指向超出(四元素)bVar 数组的第一个字节结束。在该循环的第一次迭代中,ldr 指令首先使用 X0 中的值,指向 bVar,然后在取出 X0 指向的字节后递增 X0。

3.6.7 缩放索引

缩放索引寻址模式包含两个寄存器组件(而不是一个寄存器和一个立即数),它们共同形成有效地址。该模式的语法如下:

[X`n`|SP, X`i`] 
[X`n`|SP, W`i`, `extend`] 
[X`n`|SP, X`i`, `extend`] 

第一种形式最容易理解:它通过将 Xn(或 SP)和 Xi中的值相加来计算有效地址(EA)。通常,Xn(或 SP)被称为基地址,而 Xi中的值是索引(必须是 X0 到 X30 或 XZR)。基地址是对象的最低内存地址,索引是相对于该基地址的偏移量(有点类似于间接加偏移寻址模式中的立即数)。这就是一个简单的基地址 + 索引寻址模式:不进行缩放。

基地址 + 索引形式在以下情况中非常有用:

  • 你有一个指向数组对象的指针存储在寄存器中(Xn,基地址),并且你想通过整数索引(通常是内存变量中的索引)访问该数组的元素。在这种情况下,你会将索引加载到索引寄存器(Xi)中,并使用基址+索引模式来访问实际元素。

  • 你想使用间接加偏移寻址模式,但偏移量超出了-256 到+255 的范围。在这种情况下,你可以将较大的偏移量加载到 Xi寄存器中,并使用基址+索引寻址模式访问内存位置,无论偏移量是多少。

缩放索引寻址模式的第二种和第三种形式提供了扩展/缩放操作,对于索引大于字节的数组元素来说非常有用。在这两种缩放索引模式中,一种使用 32 位寄存器作为索引寄存器,另一种使用 64 位寄存器。

32 位形式是方便的,因为大多数情况下,数组的索引都保存在 32 位整数变量中。如果将这个 32 位整数加载到 32 位寄存器(Wi)中,就可以轻松地将其用作数组的索引。

[X`n`, W`i`, `extend`] 

缩放索引寻址模式的形式。

最终,所有有效地址都是 64 位的。特别是,当 CPU 将 Xn和 Wi相加时,它必须先以某种方式将 Wi索引值扩展到 64 位,然后再进行相加。扩展操作符告诉 Gas 如何将 Wi扩展到 64 位。

扩展的最简单形式如下:

[X`n`|SP, W`i`, uxtw] 
[X`n`|SP, W`i`, sxtw] 

[Xn|SP, Wi, uxtw]形式会在将 Wi加到 Xn之前,先将 Wi零扩展到 64 位,而[Xn|SP, Wi, sxtw]形式则会在加法之前,将 Wi符号扩展到 64 位。

另一种缩放索引寻址模式引入了缩放组件。这种形式允许你根据数组元素的大小(1、2、4 或 8 字节)加载字节、半字、字或双字数组中的元素。这些特定形式并不是可以与任意的 ldr 或 str 指令一起使用的独立寻址模式。相反,每种寻址模式形式都与特定的指令大小相关联。以下是 ldrb/ldrsb 和 strb 指令的允许语法(Wd是 32 位目标寄存器,Ws是 32 位源寄存器):

ldrb  W`d`, [X`n`|SP, W`i`, sxtw #0]  // #0 is optional; 
ldrb  W`d`, [X`n`|SP, W`i`, uxtw #0]  // 0 is default shift. 
ldrb  W`d`, [X`n`|SP, X`i`, lsl #0] 

ldrsb W`d`, [X`n`|SP, W`i`, sxtw #0] 
ldrsb W`d`, [X`n`|SP, W`i`, uxtw #0] 
ldrsb W`d`, [X`n`|SP, X`i`, lsl #0] 

strb  W`s`, [X`n`|SP, W`i`, sxtw #0] 
strb  W`s`, [X`n`|SP, W`i`, uxtw #0] 
strb  W`s`, [X`n`|SP, X`i`, lsl #0] 

这些形式会对 Wi(或 Xi)进行零扩展或符号扩展,并将结果与 Xn相加,生成有效地址。之前的指令等价于以下形式(因为#0 是可选的):

ldrb  W`d`, [X`n`|SP, W`i`, sxtw] 
ldrb  W`d`, [X`n`|SP, W`i`, uxtw] 
ldrb  W`d`, [X`n`|SP, X`i`] 

ldrsb W`d`, [X`n`|SP, W`i`, sxtw] 
ldrsb W`d`, [X`n`|SP, W`i`, uxtw] 
ldrsb W`d`, [X`n`|SP, X`i`] 

strb  W`s`, [X`n`|SP, W`i`, sxtw] 
strb  W`s`, [X`n`|SP, W`i`, uxtw] 
strb  W`s`, [X`n`|SP, X`i`] 

对于 ldrh/ldrsh 和 strh 指令,你可以指定 0(×1)或 1(×2)作为缩放因子:

ldrh  W`d`, [X`n`|SP, W`i`, sxtw #1]  // #0 is also legal, or 
ldrh  W`d`, [X`n`|SP, W`i`, uxtw #1]  // no immediate value (which 
ldrh  W`d`, [X`n`|SP, X`i`, lsl #1]   // defaults to 0). 

ldrsh W`d`, [X`n`|SP, W`i`, sxtw #1] 
ldrsh W`d`, [X`n`|SP, W`i`, uxtw #1] 
ldrsh W`d`, [X`n`|SP, X`i`, lsl #1] 

strh  W`s`, [X`n`|SP, W`i`, sxtw #1] 
strh  W`s`, [X`n`|SP, W`i`, uxtw #1] 
strh  W`s`, [X`n`|SP, X`i`, lsl #1] 

在缩放因子为#1 时,这些寻址模式计算 Wi × 2 或 Xi × 2(在进行任何零扩展或符号扩展后),然后将结果与 Xn中的值相加以生成 EA。这会缩放 EA,以访问半字值(每个数组元素 2 个字节)。如果缩放因子为#0,则不进行缩放,因为缩放因子是 2⁰。前面的代码必须将 Wi或 Xi乘以适当的缩放因子(如有需要)。

对于 32 位的 ldr 指令(Wd为目标寄存器)和 str 指令(Ws为 32 位源寄存器),允许的缩放因子为 0(×1)或 2(×4):

ldr  W`d`, [X`n`|SP, W`i`, sxtw #2]  // #0 is also legal, or 
ldr  W`d`, [X`n`|SP, W`i`, uxtw #2]  // no immediate value (which 
ldr  W`d`, [X`n`|SP, X`i`, lsl #2]   // defaults to 0). 

str  W`s`, [X`n`|SP, W`i`, sxtw #2] 
str  W`s`, [X`n`|SP, W`i`, uxtw #2] 
str  W`s`, [X`n`|SP, X`i`, lsl #2] 

最后,对于 64 位的 ldr 和 str 指令,允许的缩放因子是 0(×1)和 3(×8):

ldr  X`d`, [X`n`|SP, W`i`, sxtw #3]  // #0 is also legal, or 
ldr  X`d`, [X`n`|SP, W`i`, uxtw #3]  // no immediate value (which 
ldr  X`d`, [X`n`|SP, X`i`, lsl #3]   // defaults to 0). 

str  X`s`, [X`n`|SP, W`i`, sxtw #3] 
str  X`s`, [X`n`|SP, W`i`, uxtw #3] 
str  X`s`, [X`n`|SP, X`i`, lsl #3] 

在下一章中,你将看到缩放索引寻址模式的主要用途,届时会讨论如何访问数组元素。

3.7 地址表达式

通常,在访问内存中的变量和其他对象时,你需要访问位于变量前后的位置,而不是变量本身的地址。例如,在访问数组元素或结构体字段时,确切的元素或字段可能并不位于变量的地址本身。地址表达式提供了一种机制,可以在变量地址的偏移量处访问内存。

考虑以下合法的 Gas 语法,用于表示内存地址。这并不是一种新的寻址模式,而只是对 PC 相对寻址模式的扩展:

`varName` + `offset`

这种形式通过将常量偏移量加到变量的地址来计算其有效地址。例如,指令

ldr w0, i + 4

将 W0 寄存器加载为内存中距离 i 对象(假设它位于.text 段)4 个字节的字数据;见图 3-10。

图 3-10:使用地址表达式访问超出变量的数据

这个例子中的偏移量值必须是常量(例如,3)。如果 Index 是一个字变量,那么 varName + Index 就不是一个合法的地址表达式。如果你希望指定一个在运行时变化的索引,则必须使用间接寻址模式或缩放索引寻址模式之一。还要记住,varName + offset 中的偏移量是字节地址。除非 varName 是字节数组,否则这不能正确地为一个对象数组建立索引。

在使用 PC 相对寻址模式时,ARM CPU 不允许使用 ldrb ldrh 指令。你只能在使用这种寻址模式时加载字或双字。此外,因为这些指令不会编码偏移量的低 2 位,所以你通过地址表达式指定的任何偏移量必须是 4 的倍数。

到目前为止,寻址模式示例中的偏移量始终是单一的数字常量。然而,Gas 也允许常量表达式出现在任何合法的偏移量位置。一个常量表达式由一个或多个常量项组成,这些常量项通过加法、减法、乘法、除法等运算符进行操作,具体如表 3-4 所示。请注意,同一优先级的运算符是左结合的。

表 3-4:Gas 常量表达式运算符

操作符 优先级 描述
+ 3 一元加号(对表达式没有影响)
- 3 一元负号(取反表达式)
* 2 乘法
/ 2 除法
<< 2 左移
>> 2 右移
| 1 按位或
& 1 按位与
^ 1 按位异或
! 1 按位与非
+ 0 加法
- 0 减法

然而,大多数地址表达式仅涉及加法、减法、乘法,有时还涉及除法。考虑以下示例:

ldr w0, X + 2*4

该指令将把地址 X + 8 处的字节移动到 W0 寄存器中。

值 X + 2*4 是一个地址表达式,它总是在编译时计算,而不是在程序运行时计算。当 Gas 遇到前面的指令时,它会计算

2 × 4

该结果立即添加到.text 段中的 X 基地址上。Gas 将此单一的和(X 的基地址加 8)作为指令的一部分进行编码;它不会发出额外的指令(那样会浪费时间)在运行时为你计算这个和。因为 Gas 在编译时计算地址表达式的值,所以在编译程序时 Gas 无法知道变量的运行时值,因此表达式中的所有组成部分必须是常量。

地址表达式在访问内存中的数据时非常有用,特别是当你在.data 或.text 段中使用了像.byte、.hword、.word 等指令,向数据声明后附加了额外的值时。例如,考虑在示例 3-1 中的程序,使用地址表达式访问与内存对象 i 关联的四个连续的字(每个字在内存中相隔 4 字节)。

// Listing3-1.S
//
// Demonstrates address expressions

#include "aoaa.inc"

            .data
saveLR:     .dword      0
outputVal:  .word       0

ttlStr:     .asciz      "Listing 3-1"
fmtStr1:    .asciz      "i[0]=%d "
fmtStr2:    .asciz      "i[1]=%d "
fmtStr3:    .asciz      "i[2]=%d "
fmtStr4:    .asciz      "i[3]=%d\n"

            .text
            .extern     printf

            .align      2
i:          .word       0, 1, 2, 3

// Return program title to C++ program:

            .global     getTitle
getTitle:
            lea         x0, ttlStr
            ret

// Here is the asmMain function:

            .global     asmMain
asmMain:

// "Magic" instruction offered without
// explanation at this point:

            sub     sp, sp, #256

// Save LR so we can return to the C++
// program later:

            lea     x0, saveLR
            str     lr, [x0]

// Demonstrate the use of address expressions:

            lea     x0, fmtStr1
          ❶ ldr     w1, i + 0
            lea     x2, outputVal
            str     w1, [x2]
            vparm2  outputVal
            bl      printf

            lea     x0, fmtStr2
          ❷ ldr     w1, i + 4
            lea     x2, outputVal
            str     w1, [x2]
            vparm2  outputVal
            bl      printf

            lea     x0, fmtStr3
          ❸ ldr     w1, i + 8
            lea     x2, outputVal
            str     w1, [x2]
            vparm2  outputVal
            bl      printf

            lea     x0, fmtStr4
          ❹ ldr     w1, i + 12
            lea     x2, outputVal
            str     w1, [x2]
            vparm2  outputVal
            bl      printf

            lea     x0, saveLR
            ldr     lr, [x0]
            add     sp, sp, #256
            ret

从位置 i + 0 加载 W1 会从字数组❶中获取 0。 从位置 i + 4 加载 W1 会从数组中的第二个字获取 1,该字位于第一个元素 4 字节之外❷。 从位置 i + 8 加载 W1 会从数组中的第三个字获取 2,该字位于第一个元素 8 字节之外❸。 从位置 i + 12 加载 W1 会从数组中的第四个字获取 3,该字位于第一个元素 12 字节之外❹。

以下是程序的输出:

$ ./build Listing3-1
$ ./Listing3-1
Calling Listing3-1:
i[0]=0 i[1]=1 i[2]=2 i[3]=3
Listing3-1 terminated

由于 i 地址处的值为 0,输出会显示四个值 0、1、2 和 3,仿佛它们是数组元素。地址表达式 i + 4 告诉 Gas 获取位于 i 地址加 4 的位置的字。这个值是 1,因为该程序中的.word 语句在(字/4 字节)值 0 之后将值 1 写入.text 段。类似地,对于 i + 4 和 i + 8,该程序分别显示值 2 和 3。

3.8 获取内存对象的地址

到目前为止,本书已经使用 lea 宏来获取内存对象的地址。现在,本章提供了必要的前提信息,接下来,我们将不再将 lea 视为一个黑盒,而是深入探讨这个宏究竟为你做了什么。

ARM CPU 提供了两条指令来计算汇编语言程序中符号的有效地址。第一条是 adr:

adr  X`d`, `label`

该指令将 64 位目标寄存器(Xd)加载为指定标签的地址。由于指令编码(操作码,或操作码)限制为 32 位,因此 adr 指令有一个重要的限制:它在操作码中只能容纳一个 21 位的偏移量,因此标签必须是相对于程序计数器(PC)的地址,并且距离 adr 指令的距离在±1MB 以内。这实际上将 adr 指令限制为只能获取.text 段内符号的地址。

为了解决这个问题,ARM CPU 还提供了 adrp(页面地址)指令。该指令的语法与 adr 指令大致相同:

adrp X`d`, `label`

该指令将包含标签的 MMU 页面的地址加载到目标寄存器中。通过将标签在该页面中的偏移量加到 Xd中的值,你可以获得内存对象的实际地址,代码大致如下:

adrp X`d`, `label`
add  X`d`, X`d`, `page_offset_of_label`

到此为止,Xd将包含标签的地址。

这种方案存在几个问题:首先,计算标签符号的页面偏移在 macOS 和 Linux 中的方式不同。其次,当你使用上述语法尝试 adrp 指令时,你会发现 Gas 在 macOS 上会拒绝该语法。

让我们首先考虑 Linux 对这些问题的解决方案,因为它们比 macOS 的解决方案稍微简单一些。如果你没有创建 PIE 应用程序,并且符号距离不超过±1MB,那么你不需要使用 adrp 指令。相反,你可以仅使用 adr 指令。如果数据距离 adr 指令超过±1MB,则必须使用 adrp 版本。如果你需要引用.text 段之外的内存对象,则必须使用 adrp/add 序列。以下是执行此操作的代码:

adrp x0, `label`
add  x0, x0, :lo12:`label`

:lo12: 项目是一个特殊的操作符,告诉 Gas 提取标签的可重定位地址的 LO 12 位;这个值是指向一个 4,096 字节内存管理页面的索引。有关此操作符的更多信息,请参见第 3.12 节,“更多信息”,位于 第 167 页。不幸的是,macOS 汇编器使用完全不同的语法来获取地址的 LO 12 位;你必须使用以下内容:

adrp x0, `label`@PAGE
add  x0, x0, `label`@PAGEOFF

lea 宏解决了这个问题,自动扩展成适用于你使用的操作系统的适当指令序列。

3.9 推送和弹出操作

ARM 在内存的堆栈段中维护一个硬件堆栈(操作系统为此保留存储空间)。堆栈 是一种动态数据结构,随着程序的需要而增长和缩小。它还存储程序的关键信息,包括局部变量、子程序信息和临时数据。

ARM CPU 通过 SP 寄存器控制其堆栈。当程序开始执行时,操作系统将 SP 初始化为堆栈内存段中最后一个内存位置的地址。数据通过推送数据到堆栈和弹出数据从堆栈中移出,写入堆栈段。

ARM 堆栈必须始终保持 16 字节对齐——也就是说,SP 寄存器必须始终包含一个 16 的倍数值。如果你将 SP 寄存器加载一个不满足 16 字节对齐的值,应用程序将立即因总线错误而终止。堆栈的主要用途之一是提供一个临时存储区域,用来保存如寄存器值等内容。你通常会将一个寄存器的值推入堆栈,执行一些操作(如调用一个函数),然后在你想恢复该值时,将其从堆栈弹出并放回寄存器中。然而,通用寄存器只有 64 位(8 字节);将一个双字值推入堆栈将导致堆栈不再 16 字节对齐,从而导致系统崩溃。

在本节中,我将描述如何推送和弹出寄存器值。接着,我将介绍三种解决推送不保持堆栈 16 字节对齐的双字值问题的方法:浪费存储;同时推送两个寄存器;以及在堆栈上保留存储空间,然后将寄存器的数据移动到该预留区域。

3.9.1 使用双重加载和存储

ldp 指令将同时从内存中加载两个寄存器。该指令的通用语法如下所示:

ldp  X`d`1, X`d`2, `mem`  // `mem` is any addressing mode
ldp  W`d`1, W`d`2, `mem`  // except PC-relative.

第一种形式将从指定的内存位置加载 Xd1,并从 8 字节后的位置加载 Xd2。第二种形式将从指定的内存位置加载 Wd1,并从 4 字节后的位置加载 Wd2。

stp 指令具有类似的语法,它将一对寄存器存储到相邻的内存位置:

stp  X`d`1, X`d`2, `mem`  // Store X`d`1 to mem, X`d`2 to mem + 8.
stp  W`d`1, W`d`2, `mem`  // Store W`d`1 to mem, W`d`2 to mem + 4.
                   // `mem` is any addressing mode except
                   // PC-relative.

这些指令有许多用途。就栈的使用而言,加载和存储一对 64 位寄存器的形式将一次操作 16 字节——这正是你在推送和弹出栈上数据时所需要的。

3.9.2 执行基本的推入操作

许多 CPU,如 Intel x86-64,提供了一个显式指令,可以将寄存器压入栈中。由于 16 字节栈对齐要求,你不能将一个单独的 8 字节寄存器压入栈中(否则会产生栈错误)。然而,如果你愿意在栈上使用 16 字节的空间来保存一个寄存器的值,你可以使用以下指令将该寄存器的值压入栈中:

str X`s`, [sp, #-16]! 

请记住,预索引寻址模式会首先将 -16 加到 SP 上,然后将 Xs(源寄存器)的值存储到 SP 指向的新位置。该存储操作只会写入由 SP 向下移动 16 字节创建的 16 字节块的低 8 字节(浪费高 8 字节)。然而,这种方案让 CPU 保持正常运行,因此你不会遇到总线错误。

该推入操作执行了以下操作:

SP := SP - 16 
[SP] := X`s` 

例如,假设 SP 的值为 0x00FF_FFE0,指令

str x0, [sp, #-16]! 

会将 SP 设置为 0x00FF_FFD0,并将 X0 的当前值存储到内存位置 0x00FF_FFD0,如图 3-11 和图 3-12 所示。

图 3-11:str x0, [sp, #-16]! 操作前的栈段

str 指令执行后,栈的情况如图 3-12 所示。

图 3-12:执行 str x0, [sp, #-16]! 操作后的栈段

虽然这会浪费栈上的 8 字节空间(显示在地址 0x00FF_FFD8 到 0x00FF_FFDF 之间),但这种使用可能是暂时的,栈空间将在程序稍后弹出数据时被回收。

3.9.3 执行基本的弹出操作

弹出操作可以通过使用后索引寻址模式和 ldr 指令来处理:

ldr X`d`, [sp], #16 

该指令从栈中获取数据(SP 所指向的位置),并将数据复制到目标寄存器(Xd)。操作完成后,该指令会调整 SP 值,减少 16,使其恢复到原始值(即推送操作之前的值)。图 3-13 显示了弹出操作前的栈。

图 3-13:str 操作前的栈

图 3-14 显示了执行 ldr 后的栈组织。

图 3-14:弹出操作后的栈

弹出一个值并不会清除内存中的值;它只是调整栈指针,使其指向弹出值上方的下一个值。然而,绝不要尝试访问你从栈中弹出的值。下一次将某个值压入栈时,弹出的值会被彻底覆盖。因为不仅仅是你的代码在使用栈(例如,操作系统使用栈来执行子例程),所以你不能依赖于数据在弹出栈后仍然存在于栈内存中。

3.9.4 至少保存两个寄存器

如果你需要保存至少两个寄存器,你可以通过使用 stp 指令而不是 str 来回收图 3-11 和 3-12 中显示的浪费空间。以下代码片段演示了如何同时推送和弹出 X0 和 X7:

stp  x0, x7, [sp, #-16]!
 .
 .   // Use X0 and X7 for other purposes.
 .
ldp  x0, x7, [sp], #16  // Restore X0 and X7.

将数据推入栈的第三种方法是将 SP 向下移动一个 16 字节的倍数,然后通过索引 SP 寄存器将值存储到栈区域。以下代码基本上做了与 stp/ldp 配对相同的事情:

sub  sp, sp, #16   // Make room for X0 and X7.
stp  x0, x7, [sp]
 .
 .   // Use X0 and X7 for other purposes.
 .
ldp  x0, x7, [sp]
add  sp, sp, #16

虽然这显然需要更多的指令(因此,执行时间更长),但它使得你只需在函数内为栈分配一次存储空间,并在整个函数执行过程中重用该空间。你将在第五章中看到此类示例。

3.9.5 在栈上保存寄存器值

正如你在之前的示例中看到的,栈是一个临时保存寄存器的好地方,这样它们就可以被用于其他目的。考虑以下程序大纲:

`Some instructions that use the X20 register.`

`Some instructions that need to use X20, for a`
`different purpose than the above instructions.`

`Some instructions that need the original value in X20.`

推送和弹出操作非常适合这种情况。通过在中间序列之前插入一个推送序列,并在中间序列之后插入一个弹出序列,你可以在这些计算之间保存 X20 的值:

`Some instructions that use the X20 register.`

     str x20, [sp, #-16]!

`Some instructions that need to use X20, for a`
`different purpose than the above instructions.`

 ldr x20, [sp], #16

`Some instructions that need the original value in X20.`

这个推送序列将第一个指令序列中计算出的数据复制到栈上。现在,中间指令序列可以将 X20 用于任何它选择的用途。在中间指令序列执行完毕后,弹出序列将恢复 X20 中的值,这样最后的指令序列就可以使用 X20 中的原始值。

3.9.6 在栈上保存函数返回地址

在到目前为止的示例程序中,我通过使用如下指令来保存出现在链接寄存器(LR)中的返回地址:

lea  x0, saveLR
str  lr, [x0]
 .
 .
 .
lea  x0, saveLR
ldr  lr, [x0]
ret

我也提到过,这是一种真正糟糕的保存 LR 值的方式。它需要六条指令来完成(记住,lea 会展开成两条指令),使得它比需要的更慢且更庞大。当你有一个用户编写的函数调用另一个函数时,这个方案也会产生问题:突然之间,你需要两个独立的 saveLR 变量,每个函数一个。在递归(参见第五章)或更糟糕的多线程代码中,这个机制完全失效。

幸运的是,保存返回地址到堆栈是完美的解决方案。堆栈的 LIFO 结构(见下一节)完全模拟了(嵌套)函数调用和返回的方式,而且仅需要一条指令就可以将 LR 压入堆栈或从堆栈中弹出 LR。之前的代码序列可以轻松替换为:

str  lr, [sp, #-16]!
 .
 .
 .
ldr  lr, [sp], #16
ret

使用堆栈保存和恢复 LR 寄存器可能是堆栈最常见的使用方式。第五章详细讨论了管理返回地址和其他与函数相关的值。

3.10 压栈和弹栈数据

你可以在不先弹出先前值的情况下将多个值压入堆栈。然而,堆栈是一个后进先出(LIFO) 数据结构,因此你在压栈和弹栈多个值时必须小心。

例如,假设你想在一组指令块之间保留 X0 和 X1。以下代码演示了一个明显的(但不正确的)处理方式:

str  x0, [sp, #-16]!
str  x1, [sp, #-16]!
   `Code that uses X0 and X1 goes here.`
ldr  x0, [sp], #16
ldr  x1, [sp], #16

不幸的是,这段代码无法正常工作!图 3-15 到 3-18 展示了这个问题,这些图中的每个框表示 8 字节(注意地址)。由于这段代码先压入 X0,再压入 X1,堆栈指针最终指向堆栈中 X1 的值。

图 3-15:压入 X0 后的堆栈

图 3-16 展示了压入第二个寄存器(X1)后的堆栈。

图 3-16:压入 X1 后的堆栈

当 ldr x0, [sp], #16 指令执行时,它从堆栈中移除最初在 X1 中的值并将其放入 X0(见图 3-17)。

图 3-17:弹出 X0 后的堆栈

同样,ldr x1, [sp], #16 指令将最初在 X0 中的值弹出并存入 X1 寄存器。最终,这段代码通过以与压栈顺序相同的顺序弹栈来交换寄存器中的值(见图 3-18)。

图 3-18:弹出 X1 后的堆栈

为了纠正这个问题,因为堆栈是 LIFO 数据结构,你必须首先弹出的是最后压入堆栈的内容。因此,始终按照相反的顺序弹出值。

对前一段代码的修正如下所示:

str  x0, [sp, #-16]!
str  x1, [sp, #-16]!
   `Code that uses X0 and X1 goes here.`
ldr  x1, [sp], #16
ldr  x0, [sp], #16

还要记住,始终弹出你压入的相同数量的字节。 一般来说,这意味着你需要相同数量的压栈和弹栈操作。如果弹栈操作太少,你将把数据留在堆栈中,这可能会干扰正在运行的程序。如果弹栈操作太多,你会意外地移除先前压入的数据,通常会导致灾难性的结果。

由此产生的结论是,在循环中推送和弹出数据时要小心。很容易将推送操作放在循环内,而将弹出操作放在循环外(或反之),这会导致栈不一致。记住,重要的是执行推送和弹出操作,而不是程序中推送和弹出操作的数量。运行时,程序执行的推送操作数量(及顺序)必须与弹出操作的数量(及相反顺序)匹配。

最后,记住ARM 要求栈必须按照 16 字节边界对齐。如果你在栈上进行推送和弹出操作(或使用任何其他操作栈的指令),确保在调用任何遵循 ARM 要求的函数或过程之前,栈已按照 16 字节边界对齐。

3.10.1 在不弹出数据的情况下从栈中移除数据

你可能经常会发现自己将不再需要的数据推送到栈上。虽然你可以将数据弹出到一个未使用的寄存器中,但有一种更简单的方法来移除栈中不需要的数据:只需调整 SP 寄存器中的值,以跳过栈中不需要的数据。

考虑以下困境(伪代码,不是实际汇编语言):

str  x0, [sp, #-16]!  // Push X0\. 
str  x1, [sp, #-16]!  // Push X1\. 

`Some code that winds up computing some values we want` 
`to keep in X0 and X1.` 

if(`Calculation_was_performed`) then 

      // Whoops, we don't want to pop X0 and X1! 
      // What to do here? 

else 

      // No calculation, so restore X1, X0\. 

      ldr  x1, [sp], #16 
      ldr  x0, [sp], #16 

endif; 

在 if 语句的 then 部分,这段代码希望移除 X0 和 X1 的旧值,同时不影响任何其他的寄存器或内存位置。你该如何做到这一点?

因为 SP 寄存器包含栈顶项的内存地址,我们可以通过将该项的大小加到 SP 寄存器来移除栈顶的项。在之前的示例中,我们希望从栈顶移除两个双字项。我们可以通过将 16 加到栈指针来轻松实现这一点:

str  x0, [sp, #-16]!  // Push X0 
str  x1, [sp, #-16]!  // Push X1 

`Some code that winds up computing some values we want to keep` 
`into rax and rbx.` 

if(`Calculation_was_performed`) then 

     // Remove unneeded X0/X1 values 
     // from the stack. 

     add  sp, sp, #32 

else 

     // No calculation, so restore X1, X0\. 

 ldr  x1, [sp], #16 
     ldr  x0, [sp], #16 

endif; 

实际上,这段代码从栈中弹出数据,但并未将其移到其他地方。这个代码比两个虚拟的弹出操作要快,因为它可以通过一个加法指令从栈中移除任意数量的字节。

记住,保持栈在一个四字(16 字节)边界上对齐。这意味着,当从栈中移除数据时,你应该始终将一个 16 的倍数加到 SP 寄存器中。

3.10.2 在不弹出数据的情况下访问推送到栈的数据

偶尔,你可能会将数据推送到栈中,并希望获取该数据的值的副本,或者你可能希望在不弹出数据的情况下更改该数据的值(即,你希望稍后弹出数据)。ARM 的[SP, #±offset]寻址模式提供了这一机制。

考虑执行以下指令后的栈:

stp  x0, x1, [sp, #-16]!  // Push X0 and X1\. 

这会产生图 3-19 所示的栈结果。

图 3-19:推送 X0 和 X1 后的栈

如果你想在不移除 X0 值的情况下访问其原始值,可以通过弹出该值,然后立即将其重新压入来“作弊”。不过,假设你希望访问 X1 的旧值或堆栈中更高位置的其他值。弹出所有中间值并将它们重新压入堆栈,最多是个问题,最坏情况下甚至不可能。

然而,正如图 3-19 所示,每个压入堆栈的值在内存中都位于与 SP 寄存器的某个偏移量位置。因此,我们可以使用[SP, #±offset]寻址模式直接访问我们感兴趣的值。在前面的示例中,你可以通过使用这条单指令来重新加载 X1 的原始值:

ldr  x1, [sp, #8] 

这段代码将从内存地址 SP + 8 开始的 8 个字节复制到 X1 寄存器中。这个值恰好是之前压入堆栈的 X1 值。你可以使用相同的技术访问其他已经压入堆栈的数据值。

别忘了,值从 SP 寄存器到堆栈的偏移量在每次压入或弹出数据时都会发生变化。滥用这一特性可能会导致代码难以修改;如果在代码中广泛使用此特性,将使得在你首次将数据压入堆栈和你决定再次访问这些数据之间,推送和弹出其他数据项变得困难。

上一节指出了如何通过向 SP 寄存器添加常数来从堆栈中移除数据。这个伪代码示例可能可以更安全地写成如下:

stp  x0, x1, [sp, #-16]! 

`Some code that winds up computing some values we want` 
`to keep into X0 and X1.` 

if(Calculation_was_performed) then 

     // Overwrite saved values on the stack with 
     // new X0/X1 values (so the pops that 
     // follow won't change the values in X0/X1). 

     stp  x0, x1, [sp] 

endif; 
ldp  x0, x1, [sp], #16 

在这段代码序列中,计算结果被存储在堆栈上已保存的值之上。稍后,当程序弹出这些值时,它将这些计算值加载到 X0 和 X1 寄存器中。

3.11 继续前进

本章讨论了内存的组织和访问,以及如何在 ARM CPU 上创建和访问内存变量。内容涵盖了在访问数据结构的末尾超出数据区域并跨越到新的 MMU 页面时可能出现的问题,接着讨论了小端和大端内存组织方式,以及如何使用 ARM 内存寻址模式和地址表达式,以多种方式访问这些内存对象。你还学习了如何在内存中对数据进行对齐以提高性能,如何获取内存对象的地址,以及 ARM 堆栈结构的目的。

到目前为止,本书通常只使用了基本数据类型,如不同大小的整数、字符、布尔对象和浮点数。更复杂的数据类型,如指针、数组、字符串和结构体,将在下一章讨论。

3.12 更多信息

第四章:4 常量、变量和数据类型

第二章讨论了内存中数据的基本格式,和第三章介绍了计算机系统如何在物理上组织这些数据。这一章通过将数据表示的概念与其实际物理表示连接起来,完成了对这一主题的讨论。我将集中讨论三个主要话题:常量、变量和数据结构。

本章假设你没有接受过正式的数据结构课程,尽管这种经验会很有帮助。你将学习如何声明和使用常量、标量变量、整数、数据类型、指针、数组、结构体和联合体。在进入下一章之前,务必掌握这些主题。特别是,声明和访问数组对初学汇编语言的程序员来说,似乎存在许多问题,但本书的其余部分依赖于你对这些数据结构及其内存表示的理解。不要抱着稍后根据需要再补充的心态略过这些内容;你需要立刻全面理解它。

4.1 气体常数声明

可能的第一步是通过常量声明为文字常量值附加一个名称。Gas 提供了四个指令,统称为等式,允许你在汇编语言程序中定义常量。你已经看到最常用的形式,即 .equ:

.equ `symbol`, `constantExpression`

例如:

.equ MaxIndex, 15

一旦你以这种方式声明了符号常量,就可以在程序中任何符号常量合法的地方使用这个符号标识符。这些常量被称为显式常量——符号表示,允许你在程序的任何地方将符号替换为文字常量的值。

注意

从技术上讲,你也可以使用 CPP 宏在 Gas 中定义常量。详情请见第十三章。

将此与 .rodata 对象进行对比:.rodata 值是常量值,因为在运行时你不能更改它。然而,内存位置与 .rodata 声明相关联,操作系统而不是 Gas 汇编器强制执行只读属性。虽然以下指令序列在程序运行时会崩溃,但编写它是完全合法的:

lea  x0, ReadOnlyVar
str  x1, [x0]

另一方面,使用前面声明的内容写以下内容也是不合法的:

str  x1, MaxIndex

与此写法相比:

str  x1, #15

实际上,这两个语句是等价的:编译器在遇到这个常量时,会将 15 替换为 MaxIndex。

常量声明非常适合定义在程序修改过程中可能改变的魔法数字。例子包括像 nl(换行符)、maxLen 和 NULL 这样的常量。

GNU .set 指令使用以下语法:

.set `label`, `expression`

这在语义上等价于以下内容:

`label` = `expression`

.set 和 = 指令都允许你重新定义之前用这些指令定义的符号。

例如:

maxLen = 10

`At this point in the code, Gas will replace maxLen with 10.`

maxLen = 256

`In this section of the code, maxLen gets replaced by 256.`

你将会在第十三章中看到如何利用这个功能,那里讨论了宏和 Gas 的编译时语言。

注意,.equ 也允许你重新定义源文件中的符号。这些对同一指令的多个同义词是 Gas 为保持与多种汇编器及其版本的兼容性所做的尝试。

Gas 提供的最后一个等号指令是.equiv:

.equiv `symbol, expression`

与其他三个指令不同,.equiv 会在符号已经定义的情况下产生错误。因此,除非你真的需要在程序中重新定义符号,否则这很可能是最安全的等号指令。

出现在这些等号中的表达式限制为 64 位。如果你指定一个超过 64 位的值,汇编器会报告错误。

4.2 位置计数器运算符

一个你将经常使用的非常特殊的常量是当前的定位计数器值。如前一章所述,Gas 会在常量表达式中用当前段的定位计数器的值来替代单个句点(.)。理论上,你可以使用这个运算符将指向变量的指针嵌入到该变量本身中:

ptrVar:  .dword  .   // Stores the address of ptrVar in ptrVar

然而,这并不是特别有用。更好的做法是使用位置计数器运算符来计算特定段内的偏移量和长度。如果你从位置计数器中减去段内的一个标签,得到的差值就是从代码中的那个点到指定标签的(有符号)距离。这使你能够计算字符串长度、函数长度以及其他涉及在某一段内测量字节距离的值。

这是一个使用这种技术计算字符串长度的例子:

someStr:  .ascii  "Who wants to manually count the characters"
          .asciz  "in this string to determine its length?"
ssLen     =       .-someStr

这会计算 Gas 发出的所有字节(包括字符串指令的零终止字节)。你可以使用这种技术来计算任何数据对象的长度,而不仅仅是字符串中的字符。

直观地说,位置计数器常量(.)与像 0 这样的字面常量之间有一个微妙的区别。常量 0 无论在源文件中的哪个地方出现,其值始终相同,而位置计数器常量在源文件中会有不同的值。高级语言(HLL)会将这两种常量与不同的类型关联。接下来的章节将讨论汇编语言中的类型,包括可重定位类型(位置计数器就是汇编语言中的可重定位类型)。

4.3 数据类型和 Gas

像大多数传统的(即 1960 年代的)汇编器一样,Gas 是完全无类型的。它依赖你——程序员——通过选择指令来理解你在程序中使用的所有数据类型。特别地,Gas 非常乐意接受以下任何语句:

 .text
    .align 2
wv: .word  0
      .
      .
      .
    ldr   w0, wv  // Yes, this one's "type correct."
    ldr   x0, wv  // Loads more data than is present

第二条指令从一个 32 位变量加载 64 位数据。然而,Gas 接受了这个错误的代码,并且在你指定的地址加载了 64 位数据,这个地址可能包括你在.text 段中放置的 wv 声明之后的 32 位数据。

使用错误的数据类型访问数据可能会导致代码中出现微妙的缺陷。强类型的高阶语言(HLL)有一个优势,即能够捕捉到大多数由于数据类型误用而导致的程序错误。然而,汇编语言几乎没有类型检查。类型检查是在汇编语言中的责任。接下来的 4.4 节“指针数据类型”将详细讨论这个问题。另见第 176 页中的“可重定位和绝对表达式”,它描述了 Gas 在代码中提供少量类型检查的少数几种情况。

4.4 指针数据类型

如果你在第一次接触高阶语言(HLL)中的指针时遇到过不好的经历,不必担心:在汇编语言中,指针更容易处理。你当时遇到的指针问题可能与使用它们时试图实现的链表和树形数据结构有关。另一方面,指针在汇编语言中有许多用途,这些用途与链表、树和其他复杂的数据结构无关。实际上,像数组和结构体这样简单的数据结构通常会使用指针。

指针是一个内存位置,其值是另一个内存位置的地址。不幸的是,像 C/C++这样的高阶语言(HLL)往往把指针的简单性隐藏在抽象的墙后。这种额外的复杂性往往会让程序员感到害怕,因为他们不明白幕后发生了什么。

为了说明指针是如何工作的,考虑一下 Pascal 中的以下数组声明:

M: array [0..1023] of integer;

即使你不懂 Pascal,概念也很简单。M 是一个包含 1,024 个整数的数组,索引从 M[0]到 M[1023]。每个数组元素都可以独立地保存一个整数值。换句话说,这个数组给你提供了 1,024 个整数变量,每个变量通过数字(数组索引)进行引用。

很容易看出,语句 M[0]:=100;将值 100 存储到数组 M 的第一个元素中。以下两个语句执行相同的操作:

i := 0; (* Assume "i" is an integer variable. *)
M [i] := 100;

事实上,你可以使用任何在 0 到 1,023 范围内的整数表达式作为该数组的索引。以下语句仍然执行与我们对索引 0 的单次赋值相同的操作:

i := 5;      (* Assume all variables are integers. *)
j := 10;
k := 50;
M [i*j-k] := 100;

“好吧,那到底有什么意义?”你可能会想。“任何在 0 到 1,023 范围内生成整数的东西都是合法的。那又怎样?”考虑一下下面的代码,它添加了一个有趣的间接层:

M [1] := 0;
M [M [1]] := 100;

经过一番思考,你应该明白这两条指令执行的操作与之前的示例完全相同。第一条语句将 0 存储到数组元素 M[1]中。第二条语句获取 M[1]的值,这是一个合法的数组索引,并利用该值(0)来控制它存储值 100 的位置。

如果你愿意接受这一点作为合理的,你将不会遇到指针的问题。如果你将 M 改为 memory,并假设这个数组表示系统内存,那么 M[1] 就是一个指针:即一个内存位置,其值是另一个内存位置的地址(或索引)。指针在汇编语言程序中很容易声明和使用;你甚至不需要担心数组索引。

好的,本节已经使用 Pascal 数组作为指针的示例,这没问题,但如何在 ARM 汇编语言程序中使用指针呢?

4.4.1 汇编语言中的指针使用

一个 ARM64 指针是一个 64 位的值,它可以包含另一个变量的地址。对于一个包含 0x1000_0000 的双字(dword)变量 p,p “指向”内存位置 0x1000_0000。要访问 p 所指向的双字,你可以使用类似以下的代码:

lea x0, p      // Load X0 with the 
ldr x0, [x0]   // value of pointer p. 
ldr x1, [x0]   // Fetch the data at which p points. 

通过将 p 的值加载到 X0 寄存器中,这段代码将值 0x1000_0000 加载到 X0(假设 p 的值为 0x1000_0000)。第二条指令将 X1 寄存器加载为从 X0 中偏移位置开始的双字(dword)。因为 X0 现在包含 0x1000_0000,所以这将从 0x1000_0000 到 0x1000_0007 的位置加载 X1。

为什么不直接从地址 0x1000_0000 加载 X1 呢,像这样?

lea x1, varAtAddress1000_0000 
ldr x1, [x1] 

不这样做的主要原因是,这条 ldr 指令总是从位置 varAtAddress1000_0000 加载 X1。你无法更改加载 X1 的地址。

然而,前面的指令总是从 p 所指向的位置加载 X1。这在程序控制下很容易改变。考虑以下伪代码指令序列:

 lea x0, i 
    lea x1, p      // Set p = address of i. 
    str x0, [x1] 

`Some code that sets or clears the carry flag ...` 

    bcc skipSetp 

 lea x0, j 
       lea x1, p   // Set p = address of j. 
       str x0, [x1] 
        . 
        . 
        . 

skipSetp:          // Assume both code paths wind up 
    lea x0, p      // down here. 
    ldr x0, [x0]   // Load p into X0\. 
    ldr x1, [x0]   // X1 = i or j, depending on path here. 

这个简短的例子展示了程序中的两条执行路径。第一条路径将变量 p 加载为变量 i 的地址。第二条路径将 p 加载为变量 j 的地址。两条执行路径最终汇聚到最后两条 ldr 指令,这些指令根据执行路径加载 i 或 j 到 X1。就很多方面来说,这就像是一个类似 Swift 之类高级语言中过程的参数。执行相同的指令会根据 p 中的地址(i 或 j)访问不同的变量。

4.4.2 Gas 中的指针声明

因为指针是 64 位长的,你可以使用 .dword 指令为你的指针分配存储空间:

 .data 
bb:        .byte   .-.  // Uninitialized 
           .align  3 
d:         .dword  .-.  // Uninitialized 
pByteVar:  .dword  bb   // Initialized with the address of bb 
pDWordVar: .dword  d    // Initialized with the address of d 

这个例子演示了在 Gas 中初始化和声明指针变量是可能的。你可以在 .dword 指令的操作数字段中指定静态变量的地址(.data、.rodata 和 .bss 对象),因此你可以通过使用此技术,仅用静态对象的地址来初始化指针变量。

请记住,macOS 不允许你获取 .text 部分符号的地址,因为 PIE 代码的限制。

4.4.3 指针常量和表达式

Gas 允许在指针常量合法的地方使用非常简单的常量表达式。指针常量表达式具有以下几种形式:

`StaticVarName` + `PureConstantExpression` 
`StaticVarName` - `PureConstantExpression` 

PureConstantExpression 术语是一个数值常量表达式,不涉及任何指针常量(使用 Gas 术语称为绝对常量)。这种类型的表达式生成一个内存地址,该地址位于 StaticVarName 变量前后指定字节数的位置(分别为-或+)。这里展示的前两种形式语义上是等效的:它们都返回一个指针常量,其地址是静态变量和常量表达式的和。

由于您可以创建指针常量表达式,Gas 允许您通过使用等式定义显式指针常量,这也就不足为奇了。列表 4-1 演示了如何做到这一点。

// Listing4-1.S 
//
// Pointer constant demonstration 

#include "aoaa.inc"

          .section  .rodata, ""
ttlStr:   .asciz    "Listing 4-1"
fmtStr:   .ascii    "pb's value is %p\n"
          .asciz    "*pb's value is %d\n"

          .data 
bb:       .byte     0 
          .byte     1, 2, 3, 4, 5, 6, 7 

❶ pb        =         bb + 2     // Address of "2" in bb 

❷ pbVar:   .dword     pb 

pbValue: .word      0 

         .text 
         .align     2 
         .extern    printf 

// Return program title to C++ program: 

         .global    getTitle 
getTitle: 
         lea        x0, ttlStr 
         ret 

// Here is the asmMain function: 

        .global     asmMain 
asmMain: 
        sub     sp, sp, #64     // Reserve space on stack. 
        str     lr, [sp, #56]   // Save return address. 

        lea     x0, pbVar       // Get pbVar. 
        ldr     x0, [x0] 
        ldrb    w0, [x0]        // Fetch data at *pbVar. 
      ❸ lea     x1, pbValue     // Save in pbValue for now. 
        str     w0, [x1] 

// Print the results: 

        lea     x0, fmtStr 
      ❹ vparm2  pbVar 
      ❺ vparm3  pbValue 
        bl      printf 

        ldr     lr, [sp, #56]   // Restore return address. 
        add     sp, sp, #64 
        ret     // Returns to caller 

等式 pb = bb + 2 将常量 pb 初始化为 bb 数组中第三个元素❶(索引为 2)的地址。声明 pbVar: .dword pb ❷创建一个指针变量(命名为 pbVar),并将 pb 常量的值初始化给它。因为 pb 是 bb[2]的地址,所以这条语句将 pbVar 初始化为 bb[2]的地址。程序将 pbVar 中存储的值存入 pbValue 变量❸,然后将 pbVar❹和 pbValue❺传递给 printf()函数以打印它们的值。

这里是构建命令和示例输出:

$ ./build Listing4-1 
$ ./Listing4-1 
Calling Listing4-1: 
pb's value is 0x411042 
*pb's value is 2 
Listing4-1 terminated 

打印出来的地址在不同的机器和操作系统上可能会有所不同。

4.4.4 指针变量与动态内存分配

指针变量是存储 C 标准库 malloc()函数返回结果的完美位置。该函数返回它分配的存储地址,通过 X0 寄存器返回;因此,您可以在调用 malloc()之后直接将地址存入指针变量。列表 4-2 演示了调用 C 标准库 malloc()和 free()函数的过程。

// Listing4-2.S 
//
// Demonstration of calls 
// to C stdlib malloc 
// and free functions 

#include "aoaa.inc"

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 4-2"
fmtStr:     .asciz      "Addresses returned by malloc: %p, %p\n"

            .data 
ptrVar:     .dword      .-. 
ptrVar2:    .dword      .-. 

            .text 
            .align      2 
            .extern     printf 
            .extern     malloc 
            .extern     free 

// Return program title to C++ program: 

            .global     getTitle 
getTitle: 
            lea         x0, ttlStr 
            ret 

// Here is the "asmMain" function: 

            .global     asmMain 
asmMain: 
            sub         sp, sp, #64     // Space on stack 
            str         lr, [sp, #56]   // Save return address. 

// C stdlib malloc function 
//
// ptr = malloc(byteCnt); 
//
// Note: malloc has only a single parameter; it 
// is passed in X0 as per ARM/macOS ABI. 

          ❶ mov         x0, #256        // Allocate 256 bytes. 
            bl          malloc 
            lea         x1, ptrVar      // Store pointer into 
            str         x0, [x1]        // ptrVar variable. 

            mov         x0, #1024       // Allocate 1,024 bytes. 
            bl          malloc 
            lea         x1, ptrVar2     // Store pointer into 
            str         x0, [x1]        // ptrVar2 variable. 

// Print the addresses of the two malloc'd blocks: 

            lea         x0, fmtStr 
            vparm2      ptrVar 
            vparm3      ptrVar2 
            bl          printf 

// Free the storage by calling 
// C stdlib free function. 
//
// free(ptrToFree); 
//
// Once again, the single parameter gets passed in X0\. 

          ❷ lea         x0, ptrVar 
            ldr         x0, [x0] 
            bl          free 

            lea         x0, ptrVar2 
            ldr         x0, [x0] 
            bl          free 

            ldr         lr, [sp, #56]   // Get return address. 
            add         sp, sp, #64     // Clean up stack. 
            ret 

由于 malloc()❶和 free()❷只有一个参数,您将这些参数通过 X0 寄存器传递给它们。对于 malloc()的调用,您传递一个整数值,指定您希望在堆上分配的存储量。对于 free(),您传递指向要返回给系统的存储(之前由 malloc()分配)的指针。

这里是构建命令和示例输出:

$ ./build Listing4-2 
$ ./Listing4-2 
Calling Listing4-2: 
Addresses returned by malloc: 0x240b46b0, 0x240b47c0 
Listing4-2 terminated 

如常所见,您获得的地址会因操作系统不同,甚至因程序运行不同而有所变化。

4.4.5 常见指针问题

在大多数编程语言中,程序员会遇到五种常见问题。其中一些错误会导致程序立即停止并显示诊断消息;而其他问题则更为隐蔽,可能会导致不正确的结果,或者仅仅影响程序的性能而没有报告错误。这五个问题如下:

  • 使用未初始化的指针(非法内存访问)

  • 使用包含非法值的指针(例如,NULL)

  • 在存储已被释放后继续使用通过 malloc()分配的存储

  • 在程序使用完存储后未调用 free()释放存储

  • 使用错误数据类型访问间接数据

以下子章节描述了这些问题的每个方面、其影响以及如何避免它们。

4.4.5.1 由于未初始化指针导致的非法内存访问

初学者往往没有意识到,声明一个指针变量只会为指针本身保留存储空间;它并不会为指针所引用的数据保留存储空间。因此,如果你尝试取消引用一个不包含有效内存地址的指针,就会遇到问题。列表 4-3 演示了这个问题(不要尝试编译和运行这个程序;它会崩溃)。

// Listing4-3.S 
//
// Uninitialized pointer demonstration 
// This program will not run properly. 

#include "aoaa.inc"

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 4-3"
fmtStr:     .asciz      "Pointer value= %p\n"

            .data 
❶ ptrVar:     .dword      .-.   // ".-." means uninitialized. 

            .text 
            .align      2 
            .extern     printf 

// Return program title to C++ program: 

            .global     getTitle 
getTitle: 
 lea         x0, ttlStr 
            ret 

// Here is the "asmMain" function: 

            .global     asmMain 
asmMain: 
            sub         sp, sp, #64     // Stack storage 
            str         lr, [sp, #56]   // Save return address. 

          ❷ lea         x0, ptrVar 
            ldr         x1, [x0]        // Get ptrVar into X1\. 
            ldr         x2, [x1]        // Will crash the system 

            ldr         lr, [sp, #56]   // Retrieve return adrs. 
            add         sp, sp, #64     // Restore stack. 
            ret 

尽管你在.data 段声明的变量从技术上来说是初始化过的,静态初始化仍然没有为程序中的指针❶提供一个有效的地址(而是用 0,即 NULL)。

当然,在 ARM 架构上没有真正未初始化的变量。你可以显式地为某个变量赋予初始值,也可以有些变量继承了分配存储空间时内存中的任意位模式。很多时候,这些垃圾位模式并不对应有效的内存地址。试图取消引用这样的指针(即访问指针指向的内存中的数据❷)通常会引发内存访问违规异常(段错误)。

然而,有时这些内存中的随机位恰好对应于一个你可以访问的有效内存位置。在这种情况下,CPU 将访问指定的内存位置而不会终止程序。虽然对一个天真的程序员来说,这种情况看起来可能比停止程序更可取,但实际上这更糟糕,因为你的有缺陷的程序继续运行而没有提醒你出现问题。如果你通过一个未初始化的指针存储数据,你可能会覆盖内存中其他重要变量的值。这个缺陷可能会在你的程序中产生一些难以定位的问题。

4.4.5.2 无效地址

第二个常见的问题是将无效的地址值存入指针中。前一个问题实际上是第二个问题的一个特例(无效地址是由内存中的垃圾位提供的,而不是你通过错误计算产生的)。其后果是相同的:如果你尝试取消引用一个包含无效地址的指针,要么会发生内存访问违规异常,要么会访问一个意外的内存位置。

4.4.5.3 悬空指针问题

第三个问题是在释放内存后继续使用通过 malloc()分配的存储,这也被称为悬空指针问题。为了理解这个问题,考虑以下代码片段:

mov  x0, #256 
bl   malloc       // Allocate some storage. 
lea  x1, ptrVar 
str  x0, [x1]     // Save address away in ptrVar. 
 . 
 .    ` Code that uses the pointer variable ptrVar` 
 . 
lea  x0, ptrVar   // Pass ptrVar's value to free. 
ldr  x0, [x0] 
bl   free         // Free storage associated with ptrVar. 
 . 
 .    `Code that does not change the value in ptrVar` 
 . 
lea  x0, ptrVar 
ldr  x1, [x0] 
strb w2, [x1] 

这段代码分配了 256 字节的存储空间,并将该存储的地址保存在 ptrVar 变量中。它随后使用这块 256 字节的存储一段时间,并释放该存储,将其返还给系统用于其他用途。

调用 free()不会以任何方式改变 ptrVar 的值;ptrVar 仍然指向之前 malloc()分配的内存块。ptrVar 中的值是一个悬空指针,或者称为野指针——它指向一个已被释放的存储。在这个例子中,free()并没有改变 malloc()分配的内存块中的任何数据,因此在从 free()返回后,ptrVar 仍然指向该块中由这段代码存储的数据。然而,调用 free()会告诉系统程序不再需要这个 256 字节的内存块,因此系统可以将这块内存用于其他用途。

free()函数无法强制保证你永远不会再访问这些数据;你只是承诺你不会这样做。当然,前面的代码段破坏了这个承诺;正如你在最后三条指令中看到的,程序取出了 ptrVar 中的值,并访问了它指向的内存数据。

悬空指针最大的问题是,你通常可以不受影响地使用它们。只要系统没有重用你已释放的存储,悬空指针不会产生负面影响。然而,每次调用 malloc()时,系统可能决定重用前一次调用 free()释放的内存。当这种情况发生时,任何尝试解引用悬空指针的操作都可能产生意想不到的后果。问题可能从读取被覆盖的数据(由新的、合法的数据存储使用覆盖)开始,到覆盖新数据,再到最坏的情况,覆盖系统堆管理指针,最终导致程序崩溃。解决方案很明确:在释放与指针相关的存储后,绝不再使用该指针的值。##### 4.4.5.4 内存泄漏

在本节开始时列出的所有指针问题中,未释放分配的存储可能会对程序产生最小的负面影响。以下代码片段演示了这个问题:

mov  x0, #256 
bl   malloc 
lea  x1, ptrVar 
str  x0, [x1] 

`Code that uses ptrVar` 
`This code does not free up the storage` 
`associated with ptrVar.` 

mov  x0, #512 
bl   malloc 
lea  x1, ptrVar 
str  x0, [x1] 

// At this point, there is no way to reference the original 
// block of 256 bytes pointed at by ptrVar. 

在这个例子中,程序分配了 256 字节的存储,并通过 ptrVar 变量引用它。之后,程序分配了另一个字节块,并用这个新块的地址覆盖 ptrVar 中的值。原本在 ptrVar 中的值丢失了。由于程序不再拥有这个地址值,无法调用 free()来释放存储,以供后续使用。

结果是,这 256 字节的内存不再可用于你的程序。虽然这看起来只是一个小成本,但假设这段代码处于一个重复循环中。每次执行循环时,程序都会失去另外 256 字节的内存,最终耗尽堆内存中可用的空间。这个问题通常被称为内存泄漏,因为它就像是内存中的位在程序执行过程中不断从计算机中“泄漏”出来。

内存泄漏比悬挂指针危害小得多。它们只会带来两个问题:堆空间可能耗尽的危险(这最终可能导致程序中止,尽管这种情况很少发生)和由于虚拟内存页面交换导致的性能问题。尽管如此,你应该养成在使用完所有存储后始终释放它们的习惯。当程序退出时,操作系统会回收所有存储,包括由于内存泄漏丢失的数据。因此,通过内存泄漏丢失的内存仅对你的程序丢失,而不是整个系统。

4.4.5.5 缺乏类型安全访问

因为 Gas 无法也不执行指针类型检查,你可以将数据结构的地址加载到寄存器中,并将该数据当作完全不同的类型进行访问(这通常会导致程序中的逻辑错误)。例如,考虑清单 4-4。

// Listing4-4.S 
//
// Demonstration of lack of type 
// checking in assembly language 
// pointer access 

#include "aoaa.inc"

maxLen       =          256 

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 4-4"
prompt:     .asciz      "Input a string: "
fmtStr:     .asciz      "%d: Hex value of char read: %x\n"

            .data 
valToPrint: .word       .-. 
bufIndex:   .dword      .-. 
bufPtr:     .dword      .-. 
bytesRead:  .dword      .-. 

            .text 
            .align      2 
            .extern     readLine 
            .extern     printf 
            .extern     malloc 
            .extern     free 

// Return program title to C++ program: 

            .global     getTitle 
getTitle: 
            lea         x0, ttlStr 
            ret 

// Here is the asmMain function: 

            .global     asmMain 
asmMain: 

            sub     sp, sp, #64     // Reserve stack space. 
            str     lr, [sp, #56]   // Save return address. 

// C stdlib malloc function 
// Allocate sufficient characters 
// to hold a line of text input 
// by the user: 

            mov     x0, #maxLen     // Allocate 256 bytes. 
            bl      malloc 
            lea     x1, bufPtr      // Save pointer to buffer. 
            str     x0, [x1] 

// Read a line of text from the user and place in 
// the newly allocated buffer: 

        lea     x0, prompt      // Prompt user to input 
        bl      printf          // a line of text. 

        lea     x0, bufPtr 
        ldr     x0, [x0]        // Pointer to input buffer 
        mov     x1, #maxLen     // Maximum input buffer length 
        bl      readLine        // Read text from user. 
        cmp     x0, #-1         // Skip output if error. 
        beq     allDone 
        lea     x1, bytesRead 
        str     x0, [x1]        // Save number of chars read. 

// Display the data input by the user: 

        mov     x1, #0          // Set index to 0\. 
        lea     x0, bufIndex 
        str     x1, [x0] 
dispLp: lea     x0, bufIndex    // Get buffer index 
        ldr     x1, [x0]        // into X1\. 
        lea     x2, bufPtr      // Get pointer to buffer. 
        ldr     x2, [x2] 
        ldr     w0, [x2, x1]    // Read word rather than byte! 
        lea     x1, valToPrint 
        str     w0, [x1] 
        lea     x0, fmtStr 
        vparm2  bufIndex 
        vparm3  valToPrint 
        bl      printf 

        lea     x0, bufIndex    // Increment index by 1\. 
        ldr     x1, [x0] 
        add     x1, x1, #1 
        str     x1, [x0] 

        lea     x0, bytesRead   // Repeat until 
        ldr     x0, [x0]        // you've processed "bytesRead"
        cmp     x1, x0          // bytes. 
        blo     dispLp 

// Free the storage by calling 
// C stdlib free function. 
//
// free(bufPtr) 

allDone: 
        lea     x0, bufPtr 
        ldr     x0, [x0] 
        bl      free 

        ldr     lr, [sp, #56]   // Restore return address. 
        add     sp, sp, #64 
        ret     // Returns to caller 

下面是构建并运行清单 4-4 中程序的命令:

$ ./build Listing4-4 
$ ./Listing4-4 
Calling Listing4-4: 
Input a string: Hello world 
0: Hex value of char read: 6c6c6548 
1: Hex value of char read: 6f6c6c65 
2: Hex value of char read: 206f6c6c 
3: Hex value of char read: 77206f6c 
4: Hex value of char read: 6f77206f 
5: Hex value of char read: 726f7720 
6: Hex value of char read: 6c726f77 
7: Hex value of char read: 646c726f 
8: Hex value of char read: 646c72 
9: Hex value of char read: 646c 
10: Hex value of char read: 64 
11: Hex value of char read: 0 
Listing4-4 terminated 

清单 4-4 从用户读取数据作为字符值,然后将数据以双字节十六进制值的形式显示。虽然汇编语言允许你随意忽略数据类型并自动将数据强制转换,而无需任何努力,但这种能力是一把双刃剑。如果你犯了错误,使用错误的数据类型访问间接数据,Gas 和 ARM 可能不会捕捉到这个错误,导致程序产生不准确的结果。因此,在使用指针和间接访问数据时,你需要确保在数据类型上保持一致。

这个演示程序有一个基本的缺陷,可能会给你带来问题:当读取输入缓冲区的最后两个字符时,程序访问了用户输入的字符之外的数据。如果用户输入 255 个字符(加上 readLine() 添加的零终止字节),这个程序将访问 malloc() 分配的缓冲区末尾之外的数据。理论上,这可能导致程序崩溃。这又是一个在通过指针访问数据时使用错误类型可能发生的问题。

尽管指针存在许多问题,它们在访问常见数据结构(如数组、结构体和字符串)时是必不可少的。因此,本章在介绍这些其他 复合数据类型 之前讨论了指针。然而,随着指针部分讨论完毕,现在是时候看看这些其他数据类型了。

4.5 复合数据类型

复合数据类型,也叫做 聚合数据类型,是由其他通常是标量类型的数据构建而成的。例如,字符串就是一种复合数据类型,因为它是由一系列单独的字符和其他数据构成的。以下各节将介绍几种重要的复合数据类型:字符字符串、数组、多维数组、结构体和联合体。

4.6 字符字符串

在整数值之后,字符字符串可能是现代程序中使用的最常见数据类型。本节提供了几种字符字符串的定义(普遍使用的零终止字符串、效率更高的长度前缀字符串以及这两者的其他组合),并讨论了如何处理这些字符串。

通常,字符字符串是一个由 ASCII 字符组成的序列,具有两个主要属性:长度和字符数据。不同的语言使用不同的数据结构来表示字符串。对于汇编语言(至少在没有库函数的情况下),你可以选择以任何格式实现字符串——可能基于格式与高级语言(HLL)的兼容性,或者希望提高字符串函数的执行速度。你所需要做的就是创建一系列机器指令,以处理字符串数据,不管字符串采用何种格式。

字符串也可以包含 Unicode 字符。本节中的所有示例使用 ASCII(因为 Gas 更好地支持 ASCII 字符)。这些原理同样适用于 Unicode,只需要相应地扩展所使用的存储量。

4.6.1 零终止字符串

零终止字符串是当前使用最广泛的字符串表示形式,因为这是 C、C++ 和其他语言的本地字符串格式。零终止字符串由一个或多个 ASCII 字符组成,以一个 0 字节结束。例如,在 C/C++ 中,字符串 "abc" 需要 4 个字节:三个字符 a、b 和 c,后跟一个包含 0 的字节。

要在 Gas 中创建零终止字符串,只需使用 .asciz 指令。最简单的方法是将其放在 .data 部分,代码如下:

 .data 
zeroString: .asciz  "This is the zero-terminated string"

每当字符字符串出现在 .asciz 指令中,如此处所示,Gas 会将字符串中的每个字符输出到连续的内存位置,并以一个 0 字节终止整个字符串。

对于长度超过单行源代码的零终止字符串,有几种处理方法。首先,你可以对长字符串中的每一行源代码,除了最后一行,使用 .ascii 指令。例如:

 .data 
longZString: .ascii  "This is the first line"
             .ascii  "This is the second line"
             .asciz  "This is the last line"

.asciz 指令会将整个字符串以零字节终止。但是,如果你愿意,你总是可以使用 .byte 指令显式地自己添加零终止字节:

 .data 
longZString: .ascii  "This is the first line"
             .ascii  "This is the second line"
             .ascii  "This is the last line"
             .byte   0 

使用你喜欢的方案。一些人更倾向于使用显式的 .byte 指令,因为它容易添加或删除字符串,而不必担心将 .ascii 改为 .asciz(或反之亦然)。

零终止字符串有两个主要特点:它们易于实现,并且字符串的长度可以是任意的。然而,它们也有一些缺点。首先,零终止字符串不能包含 NUL 字符(其 ASCII 码为 0)。通常这不会是问题,但偶尔也会造成困扰。其次,许多零终止字符串的操作效率较低。例如,要计算零终止字符串的长度,你必须扫描整个字符串,寻找那个 0 字节(计算字符直到 0 字节)。下面的程序片段展示了如何计算前面字符串的长度:

 lea   x1, longZString 
          mov   x2, x1         // Save pointer to string. 
whileLp:  ldrb  w0, [x1], #1   // Fetch next char and inc X1\. 
          cmp   w0, #0         // See if 0 byte. 
          bne   whileLp        // Repeat while not 0\. 
          sub   x0, x1, x2     // X0 = X1 - X2 
          sub   x0, x0, #1     // Adjust for extra increment. 

// String length is now in X0\. 

这段代码保存了初始字符串地址(在 X2 中),然后通过将最终指针(刚好在 0 字节之后)与初始地址相减来计算长度。额外的减 1 操作是因为我们通常不将零终止字节计入字符串的长度。

如你所见,计算字符串长度所需的时间与字符串的长度成正比;随着字符串变长,计算其长度的时间也变得更长。

4.6.2 长度前缀字符串

长度前缀字符串格式克服了零终止字符串的一些问题。长度前缀字符串 在像 Pascal 这样的语言中很常见;它们通常由一个长度字节和零个或多个字符值组成。第一个字节指定字符串的长度,接下来的字节(直到指定的长度)是字符数据。在长度前缀方案中,字符串 "abc" 将由 4 个字节组成:3(字符串长度),后跟 a、b 和 c。你可以通过以下代码在 Gas 中创建长度前缀字符串:

 .data 
lengthPrefixedString: .byte   3 
                      .ascii "abc"

提前计算字符数并将其插入到字节语句中,像这里做的那样,可能看起来是一件非常麻烦的事。幸运的是,有方法可以让 Gas 自动为你计算字符串的长度。

长度前缀字符串解决了零终止字符串的两个主要问题。长度前缀字符串可以包含 NUL 字符,而在零终止字符串上进行的相对低效的操作(例如,字符串长度)在使用长度前缀字符串时更加高效。然而,长度前缀字符串也有其缺点;最重要的是,它们的最大长度限制为 255 个字符(假设使用 1 字节长度前缀)。

当然,如果你遇到 255 字符的字符串长度限制问题,可以通过使用所需字节数来创建长度前缀字符串。例如,高级汇编语言(HLA)使用 4 字节长度变体的长度前缀字符串,允许字符串的长度达到 4GB。(有关 HLA 的更多信息,请参见第 4.11 节,第 221 页。)在汇编语言中,你可以根据需要定义字符串格式。

要在汇编语言程序中创建长度前缀字符串,你不想手动计算字符串中的字符并在代码中输出该长度。最好让汇编器通过使用位置计数器操作符(.)为你完成这类繁重的工作,如下所示:

 .data 
lengthPrefixedString: .byte  lpsLen 
                      .ascii "abc"
lpsLen                =      . - lengthPrefixedString - 1 

lpsLen 操作数在地址表达式中减去 1,因为

. - lengthPrefixedString 

还包括长度前缀字节,这不被视为字符串长度的一部分。

Gas 不要求你在使用 .byte 指令中的操作数字段之前定义 lpsLen。Gas 足够聪明,可以在它在等式语句中定义之后回溯并填充该值。

4.6.3 字符串描述符

另一种常见的字符串格式是字符串描述符。字符串描述符 通常是一个小型数据结构(见第 4.8 节,“结构体”,在第 212 页),包含描述字符串的多个数据项。

至少,字符串描述符可能会有指向实际字符串数据的指针和一个字段,指定字符串中的字符数(即字符串长度)。其他可能的字段包括当前字符串占用的字节数、字符串可能占用的最大字节数、字符串编码(例如,ASCII、Latin-1、UTF-8 或 UTF-16),以及字符串数据结构设计者可能想到的其他信息。

迄今为止,最常见的描述符格式包含指向字符串数据的指针以及一个大小字段,指定当前由该字符串数据占用的字节数。请注意,这种特定的字符串描述符与长度前缀字符串不同。在长度前缀字符串中,长度紧接在字符数据之前。在描述符中,长度和指针一起存放,通常与字符数据本身是分开的。

4.6.4 指向字符串的指针

通常,汇编语言程序不会直接处理出现在 .data(或 .text、.rodata、.bss)部分的字符串。相反,程序会处理指向字符串的指针(包括程序通过调用 malloc() 等函数动态分配存储的字符串)。清单 4-4 提供了一个简单(但有缺陷)的示例。在这种应用中,汇编代码通常会将指向字符串的指针加载到基址寄存器中,然后使用第二个(索引)寄存器访问字符串中的单个字符。

4.6.5 字符串函数

不幸的是,少数汇编器提供你可以从汇编语言程序中调用的字符串函数。作为汇编语言程序员,你需要自己编写这些函数。幸运的是,如果你觉得自己没有足够的能力来完成这个任务,还是有一些解决方案可以使用的。

你可以调用的第一组字符串函数,且无需自己编写,是 C 标准库中的字符串函数,这些函数位于 C 的 string.h 头文件中。当然,在调用 C 标准库函数时,你必须在代码中使用 C 字符串(以零终止的字符串),但这通常不是一个大问题。列表 4-5 提供了调用各种 C 字符串函数的示例,详细描述请见 附录 E。

// Listing4-5.S 
//
// Calling C stdlib string functions

#include "aoaa.inc"

maxLen      =           256 
saveLR      =           56 

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 4-5"
prompt:     .asciz      "Input a string: "
fmtStr1:    .asciz      "After strncpy, resultStr='%s'\n"
fmtStr2:    .asciz      "After strncat, resultStr='%s'\n"
fmtStr3:    .asciz      "After strcmp (3), W0=%d\n"
fmtStr4:    .asciz      "After strcmp (4), W0=%d\n"
fmtStr5:    .asciz      "After strcmp (5), W0=%d\n"
fmtStr6:    .asciz      "After strchr, X0='%s'\n"
fmtStr7:    .asciz      "After strstr, X0='%s'\n"
fmtStr8:    .asciz      "resultStr length is %d\n"

str1:       .asciz      "Hello, "
str2:       .asciz      "World!" 
str3:       .asciz      "Hello, World!" 
str4:       .asciz      "hello, world!" 
str5:       .asciz      "HELLO, WORLD!" 

            .data 
strLength:  .dword      .-. 
resultStr:  .space      maxLen, .-. 
resultPtr:  .dword      resultStr 
cmpResult:  .dword      .-. 

            .text 
            .align      2 
            .extern     readLine 
            .extern     printf 
            .extern     malloc 
            .extern     free 

// Some C stdlib string functions: 
//
// size_t strlen(char *str) 

            .extern     strlen 

// char *strncat(char *dest, const char *src, size_t n) 

            .extern     strncat 

// char *strchr(const char *str, int c) 

            .extern     strchr 

// int strcmp(const char *str1, const char *str2) 

            .extern     strcmp 

// char *strncpy(char *dest, const char *src, size_t n) 

            .extern     strncpy 

// char *strstr(const char *inStr, const char *search4) 

            .extern     strstr 

// Return program title to C++ program: 

            .global     getTitle 
getTitle: 
            lea         x0, ttlStr 
            ret 

// Here is the "asmMain" function. 

            .global     asmMain 
asmMain: 
        sub     sp, sp, #64         // Allocate stack space. 
        str     lr, [sp, #saveLR]   // Save return address. 

// Demonstrate the strncpy function to copy a 
// string from one location to another: 

        lea     x0, resultStr   // Destination string 
        lea     x1, str1        // Source string 
        mov     x2, #maxLen     // Max number of chars to copy 
        bl      strncpy 

        lea     x0, fmtStr1 
        vparm2  resultPtr 
        bl      printf 

// Demonstrate the strncat function to concatenate str2 to 
// the end of resultStr: 

        lea     x0, resultStr 
        lea     x1, str2 
        mov     x2, #maxLen 
        bl      strncat 

        lea     x0, fmtStr2 
        vparm2  resultPtr 
        bl      printf 

// Demonstrate the strcmp function to compare resultStr 
// with str3, str4, and str5: 

        lea     x0, resultStr 
        lea     x1, str3 
        bl      strcmp 
        lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr3 
        vparm2  cmpResult 
        bl      printf 

        lea     x0, resultStr 
        lea     x1, str4 
        bl      strcmp 
 lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr4 
        vparm2  cmpResult 
        bl      printf 

        lea     x0, resultStr 
        lea     x1, str5 
        bl      strcmp 
        lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr5 
        vparm2  cmpResult 
        bl      printf 

// Demonstrate the strchr function to search for 
// ',' in resultStr: 

        lea     x0, resultStr 
        mov     x1, #',' 
        bl      strchr 
        lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr6 
        vparm2  cmpResult 
        bl      printf 

// Demonstrate the strstr function to search for 
// str2 in resultStr: 

        lea     x0, resultStr 
        lea     x1, str2 
        bl      strstr 
        lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr7 
        vparm2  cmpResult 
        bl      printf 

// Demonstrate a call to the strlen function: 

        lea     x0, resultStr 
        bl      strlen 
        lea     x1, cmpResult 
        str     x0, [x1] 

        lea     x0, fmtStr8 
        vparm2  cmpResult 
        bl      printf 

 ldr     lr, [sp, #saveLR]   // Restore return address. 
        add     sp, sp, #64         // Deallocate storage. 
        ret     // Returns to caller 

这是来自列表 4-5 的构建命令和示例输出:

$ ./build Listing4-5 
$ ./Listing4-5 
Calling Listing4-5: 
After strncpy, resultStr='Hello, ' 
After strncat, resultStr='Hello, World!' 
After strcmp (3), W0 = 0 
After strcmp (4), W0=-128 
After strcmp (5), W0 = 128 
After strchr, X0=', World!' 
After strstr, X0='World!' 
resultStr length is 13 
Listing4-5 terminated 

当然,你可以提出一个有力的论点,如果你的所有汇编代码只是调用一堆 C 标准库函数,那么你本应一开始就用 C 编写应用程序。编写汇编语言代码的主要好处仅在于你“用”汇编语言思考,而不是 C。

特别是,如果你停止使用零终止字符串并改用另一种字符串格式(如长度前缀或基于描述符的字符串,其中包含长度组件),你可以显著提高字符串函数调用的性能。对于那些希望避免使用零终止字符串与 C 标准库一起使用的低效性的读者,第十四章介绍了一些纯汇编字符串函数。

4.7 数组

与字符串一样,数组可能是最常用的复合数据类型。然而,大多数初学者并不了解它们的内部操作或相关的效率权衡。令人惊讶的是,很多初学者(甚至是高级程序员!)一旦学会如何在机器级别处理数组后,就会从完全不同的角度来看待数组。

从抽象的角度来看,数组是一种聚合数据类型,其成员(元素)类型相同。选择数组中的成员是通过整数索引(或其他顺序类型,如布尔值或字符)。不同的索引选择数组中的独特元素。本书假设整数索引是连续的,尽管这并非必须的。也就是说,如果数字 x 是有效的数组索引,且 y 也是有效的索引,且 x < y,那么所有满足 x < i < yi 都是有效的索引。大多数高级语言使用连续的数组索引,它们是最有效的,因此在此使用。

每当你将索引操作符应用于数组时,结果就是由该索引选择的特定数组元素。例如,A[i] 选择数组 A 中的第 i 个元素。在内存中,并没有正式要求元素 i 和元素 i + 1 彼此接近;只要 A[i] 始终指向相同的内存位置,且 A[i + 1] 始终指向其对应位置(并且两者不同),那么数组的定义就满足要求。

如前所述,本书假设数组元素占据内存中的连续位置。具有五个元素的数组将在内存中显示为 图 4-1 所示。

图 4-1:内存中的数组布局

数组的基地址是该数组第一个元素的地址,并且总是出现在最低的内存位置。第二个数组元素紧跟在第一个元素后面,第三个元素紧跟在第二个元素后面,以此类推。索引不一定要求从 0 开始。它们可以从任何数字开始,只要是连续的。然而,为了讨论的方便,本书将所有索引从 0 开始。

要访问数组的元素,你需要一个函数将数组索引转换为被索引元素的地址。对于一维数组,这个函数非常简单:

`Element_Address` =
 `Base_Address` + `((Index` - `Initial_Index)` × `Element_Size)` 

这里,Initial_Index 是数组中第一个索引的值(如果为 0,则可以忽略),Element_Size 是单个数组元素的大小,以字节为单位(这可能包括用于保持元素对齐的填充字节)。

4.7.1 在 Gas 程序中声明数组

在访问数组元素之前,必须为该数组预留存储空间。幸运的是,数组声明基于你已经看到的声明。为了为数组分配 n 个元素,你可以在其中一个变量声明部分使用如下声明:

`ArrayName`: .fill  `n`, `element_size`, `initial_value` 

ArrayName 是数组变量的名称,n 是数组元素的数量,element_size 是单个元素的大小(以字节为单位),initial_value 是要分配给每个数组元素的初始值。element_size 和 initial_value 参数是可选的,默认为 1 和 0。

例如,要声明一个包含 16 个 32 位字的数组,可以使用以下代码:

wordArray:   .fill    16, 4 

这将为 16 个 4 字节的字保留空间,每个字的初始值为 0(默认初始值)。

element_size 的值不能超过 8;如果超过,Gas 会将值截断为 8。出于历史(Gas)原因,建议将初始值限制为 32 位;更大的值会以不直观的方式进行转换(并且在 macOS 和 Linux 上有所不同)。作为一般规则,我强烈建议在使用 .fill 指令时,将每个数组元素的默认初始值设置为 0。

注意

如果在 .bss 部分使用 .fill 指令,则初始值必须为空或设置为 0。

.fill 指令的替代方法是 .space

`ArrayName:`  .space `size`, `fill` 

其中 size 是为数组分配的字节数,fill 是一个可选的 8 位值,Gas 将使用它初始化数组的每个字节。如果没有提供 fill 参数,Gas 会使用默认值 0。

要声明一个非字节类型的数组,必须计算出大小参数,即 numberOfElements × elementSize。例如,要创建一个 16 元素的字数组,可以使用以下声明:

wordArray:   .space    16 * (4)   // word wordArray[16] 

由于填充参数不存在,Gas 将使用包含 0 的字节初始化该数组。我建议在表达式中将元素大小放在括号中,以更好地记录你的意图;这可以将元素大小与元素计数区分开来。正如你在第 4.7.4 节“实现多维数组”中所见,第 203 页,元素计数可能是基于每个维度大小的表达式。

要获取这些数组的基地址,只需在地址表达式中使用 ArrayName 或 wordArray。如果你更倾向于为每个元素初始化不同的值,你必须在指令 .byte、.hword、.word、.dword 等中手动提供这些值。以下是一个用值 0 到 15 初始化的 16 字元素数组:

wordArray:    .word   0, 1, 2, 3, 4, 5, 6, 7 
              .word   8, 9, 10, 11, 12, 13, 14, 15 

如果你需要用不同的值初始化一个大数组,最好是编写一个外部程序(可能是用 C/C++ 等高级语言编写),或者使用 Gas 的宏功能来生成该数组。我在第十章和第十三章中进一步讨论了这个问题。

4.7.2 访问一维数组的元素

要访问一个零基数组的元素,请使用以下公式:

`Element_Address` = `Base_Address` + `index` × `Element_Size` 

如果数组位于你的 .text 区段内(常量数组),或者你正在编写一个 Linux 应用程序且数组与访问该数组的代码的距离不超过±1MB,你可以使用数组的名称作为 Base_Address 条目的值。这是因为 Gas 将数组第一个元素的地址与该数组的名称关联起来。

否则,你需要将数组的基地址加载到 64 位寄存器中。例如:

lea x1, `Base_Address` 

Element_Size 条目表示每个数组元素的字节数。如果对象是字节数组,则 Element_Size 字段为 1(导致非常简单的计算)。如果数组的每个元素是半字(或其他 2 字节类型),则 Element_Size 为 2,以此类推。要访问前面章节中的 wordArray 数组的元素,你可以使用以下公式(大小为 4,因为每个元素是一个字对象):

`Element_Address` = wordArray + (`index` × 4) 

等价于语句 w0 = wordArray[index] 的 ARM 代码如下:

lea x1, index   // Assume index is a 32-bit integer. 
ldr w1, [x1]    // Get index into W1\. 
lea x2, wordArray 
ldr w0, [x2, w1, uxtw #2] // index * 4 and zero-extended 

该指令序列并未明确计算基地址加上索引乘以 4(即 wordArray 中 32 位整数元素的大小)。相反,它依赖于缩放索引寻址模式(uxtx #2 操作数)来隐式计算这个和。该指令

ldr w0, [x2, w1, uxtw #2] 

从位置 X2 + W1 * 4 加载 W0,这是基地址加上索引 * 4(因为 W1 包含索引)。

要乘以 1、2、4 或 8 以外的常数(这是使用缩放索引寻址模式时可能的立即移位常数),你需要使用 lsl 指令来乘以元素大小(如果乘以 2 的幂),或者使用 mul 指令。稍后会看到一些示例。

ARM 上的缩放索引寻址模式是访问一维数组元素的自然寻址模式。确保记得将索引乘以元素的大小;如果不这么做,会导致不正确的结果。

本节中的示例假设索引变量是 32 位值,这是数组索引常见的类型。如果要使用较小的整数,您需要将其扩展为 32 位。如果要使用 64 位整数,只需调整缩放索引寻址模式以使用 64 位索引寄存器,并使用左移缩放,而无需零扩展或符号扩展。

4.7.3 排序一个值的数组

在介绍数组时,书籍通常会介绍数组元素的排序。为了承认这一历史先例,本节简要介绍了在 Gas 中的简单排序。此节中呈现的程序使用了冒泡排序的变体,它对于短列表数据和几乎已排序的列表非常有效,但对于其他几乎所有情况都表现不佳。然而,冒泡排序易于实现和理解,这也是为什么本书及其他入门书籍继续在示例中使用它的原因。

由于列表 4-6 的相对复杂性,我将在源代码中插入注释,而不是在最后解释。我们首先像往常一样包含 aoaa.inc

// Listing4-6.S 
//
// A simple bubble sort example 

#include "aoaa.inc"

立刻开始进行一些编码改进,相较于本书中许多前面的示例。那些示例,如列表 4-1,使用了“魔法”数字,比如 64 表示分配的堆栈空间大小,56 表示我保留 LR 寄存器的堆栈分配偏移量。我在代码中直接使用了这些字面常量,以便尽可能地透明;然而,良好的编程风格要求使用符号名称替代这些魔法数字。下面的两个等式就完成了这一点。

// Listing4-6.S (cont.) 

stackAlloc  =   64   // Space to allocate on stack 
saveLR      =   56   // Save LR here (index into stack frame). 

源文件中的接下来的几行语句定义了堆栈框架中的偏移量(分配的堆栈存储区域),程序可以在其中保存寄存器值。在迄今为止的所有示例程序中,我将(全局)变量放置在内存位置。这不是 RISC 汇编语言编程的合适范式。

ARM ABI 保留寄存器 X19 到 X28 用作非易失性(永久)变量存储。非易失性意味着您可以调用函数(如 printf()),而无需担心这些寄存器的值会被更改。使用非易失性寄存器的缺点是,进入代码时必须保留它们的值。以下两个等式指定了用于寄存器保留的堆栈分配区的偏移量。此代码将使用寄存器 X19 和 X20 作为循环控制变量:

// Listing4-6.S (cont.) 

x19Save     =   saveLR - 8   // Save X19 here. 
x20Save     =   x19Save - 8  // Save X20 here. 

剩余的等式定义了代码中使用的其他常量:

// Listing4-6.S (cont.) 

maxLen      =   256 
true        =   1 
false       =   0 

接下来是常规的只读和可写数据段。特别是,.data 段包含 sortMe 数组,它将成为排序操作的对象。此外,这段语句还包含 getTitle 函数,该函数是 c.cpp 程序所需的:

// Listing4-6.S (cont.) 

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 4-6"
fmtStr:     .asciz      "Sortme[%d] = %d\n"

            .data 

// sortMe - A 16-element array to sort: 

sortMe: 
            .word   1, 2, 16, 14 
            .word   3, 9, 4,  10 
            .word   5, 7, 15, 12 
            .word   8, 6, 11, 13 
sortSize    =       (. - sortMe) / 4   // Number of elements 

// Holds the array element during printing: 

valToPrint: .word   .-. 
i:          .word   .-. 

            .text 
            .align  2 
            .extern printf 

// Return program title to C++ program: 

            .global getTitle 
getTitle: 
            lea     x0, ttlStr 
            ret 

现在我们来看看冒泡排序函数本身:

// Listing4-6.S (cont.) 
//
// Here's the bubble-sort function. 
//
//       sort(dword *array, qword count) 
//
// Note: this is not an external (C) 
// function, nor does it call any 
// external functions, so it will 
// dispense with some of the OS-calling-
// sequence stuff. 
//
// array- Address passed in X0 
// count- Element count passed in X1 
//
// Locals: 
//
// W2 is "didSwap" Boolean flag. 
// X3 is index for outer loop. 
// W4 is index for inner loop. 

冒泡排序函数可以直接使用寄存器名称,比如 X0、X1、W2 和 X3 来作为所有局部变量。然而,以下的 #define 语句允许你使用更具意义的名称。X5、X6 和 X7 是纯粹的临时寄存器(没有附加任何有意义的名称),所以这段代码继续使用 ARM 寄存器名称来表示这些临时或局部对象。技术上讲,X0 到 X7 是为参数保留的。由于排序函数只有两个参数(数组和计数),它使用 X2 到 X7 作为局部变量(这是可以的,因为根据 ARM ABI,这些寄存器是易失性的):

// Listing4-6.S (cont.) 

#define array   x0 
#define count   x1 
#define didSwap w2 
#define index   x3 

刚刚定义的 count 参数包含数组元素的数量(在主程序中将为 16)。因为使用字节计数比使用(字)元素计数更为方便,所以以下代码将 count(X1)乘以 4,方法是将其左移 2 位。此外,循环执行 count – 1 次,所以这段代码也通过减去 1 来预处理 count

// Listing4-6.S (cont.) 

sort:

        sub     count, count, #1   // numElements - 1 
        lsl     count, count, #2   // Make byte count. 

冒泡排序通过进行 count - 1 次遍历数组来工作,其中 count 是元素的数量。在每次遍历中,它比较每一对相邻的数组元素;如果第一个元素大于第二个元素,程序就会交换它们。在每次遍历的末尾,一个元素将被移动到它的最终位置。作为一种优化,如果没有发生交换,那么所有元素已经就位,排序将终止:

// Listing4-6.S (cont.) 
//
// Outer loop 

outer:  mov     didSwap, #false 

        mov     index, #0          // Outer loop index 
inner:  cmp     index, count       // while outer < count - 1 
        bhs     xInner 

        add     x5, array, index   // W5 = &array[index] 
        ldr     w6, [x5]           // W6 = array[index] 
        ldr     w7, [x5, #4]       // W7 = array[index + 1] 
        cmp     w6, w7             // If W5 > W 
        bls     dontSwap           // then swap. 

        // sortMe[index] > sortMe[index + 1], so swap elements. 

        str     w6, [x5, #4] 
        str     w7, [x5] 
        mov     didSwap, #true 

dontSwap: 
        add     index, index, #4    // Next word 
        b.al    inner 

// Exited from inner loop, test for repeat 
// of outer loop: 

xInner: cmp     didSwap, #true 
        beq     outer 

        ret 

主程序首先通过保存它使用的不可变寄存器(LR, X19 和 X20)来开始:

// Listing4-6.S (cont.) 
//
// Here is the asmMain function: 

            .global asmMain 
asmMain: 

            sub     sp, sp, #stackAlloc   // Allocate stack space. 
            str     lr, [sp, #saveLR]     // Save return address. 
            str     x19, [sp, #x19Save]   // Save nonvolatile 
            str     x20, [sp, #x20Save]   // X19 and X20\. 

接下来,主程序调用排序函数来对数组进行排序。根据 ARM ABI,程序将第一个参数(数组的地址)传递给 X0,第二个参数(元素计数)传递给 X1:

// Listing4-6.S (cont.) 
//
// Sort the "sortMe" array: 

            lea     x0, sortMe 
            mov     x1, #sortSize   // 16 elements in array 
            bl      sort 

一旦排序完成,程序执行一个循环来显示数组中的 16 个值。这个循环使用不可变寄存器 X19 和 X20 来保存数组的基地址和循环索引,因此这些值在每次循环迭代时不需要重新加载。因为它们是不可变的,我们知道 printf() 不会干扰它们的值:

// Listing4-6.S (cont.) 
//
// Display the sorted array. 

            lea     x19, sortMe 
            mov     x20, xzr                 // X20 = 0 (index) 
dispLp:     ldr     w0, [x19, x20, lsl #2]   // W0 = sortMe[X20] 
            lea     x1, valToPrint 
            str     w0, [x1] 
            lea     x1, i 
            str     x20, [x1] 

            lea     x0, fmtStr      // Print the index 
            vparm2  i               // and array element 
            vparm3  valToPrint      // on this loop iteration. 
            bl      printf 

            add     x20, x20, #1    // Bump index by 1\. 
            cmp     x20, #sortSize  // Are we done yet? 
            blo     dispLp 

一旦输出完成,主程序必须在返回 C++ 程序之前恢复这些不可变寄存器:

// Listing4-6.S (cont.) 

            ldr     x19, [sp, #x19Save]  // Restore nonvolatile 
            ldr     x20, [sp, #x20Save]  // registers. 
            ldr     lr, [sp, #saveLR]    // Restore rtn adrs. 
            add     sp, sp, #stackAlloc  // Restore stack. 
            ret     // Returns to caller 

你可以通过使用 stpldp 指令来稍微优化这个程序,从而同时保存 X19 和 X20 寄存器。为了强调将保存和恢复两个寄存器作为独立操作,我在这里没有进行这个优化。然而,你应该养成以这种方式优化代码的习惯,以便能够享受到使用汇编语言的好处。

这是第 4-6 号清单的构建命令和输出:

$ ./build Listing4-6 
$ ./Listing4-6 
Calling Listing4-6: 
Sortme[0] = 1 
Sortme[1] = 2 
Sortme[2] = 3 
Sortme[3] = 4 
Sortme[4] = 5 
Sortme[5] = 6 
Sortme[6] = 7 
Sortme[7] = 8 
Sortme[8] = 9 
Sortme[9] = 10 
Sortme[10] = 11 
Sortme[11] = 12 
Sortme[12] = 13 
Sortme[13] = 14 
Sortme[14] = 15 
Sortme[15] = 16 
Listing4-6 terminated 

像典型的冒泡排序算法一样,如果最内层的循环在没有交换任何数据的情况下完成,算法就会终止。如果数据已经是预排序的,冒泡排序是非常高效的,仅需对数据进行一次遍历。不幸的是,如果数据没有排序(或者,最糟糕的情况是,数据按反向顺序排序),那么这个算法非常低效。第五章提供了一个更高效的排序算法——快速排序的 ARM 汇编语言示例。

4.7.4 实现多维数组

ARM 硬件可以轻松处理一维数组。然而,不幸的是,访问多维数组的元素需要一些工作并且需要多个指令。

在讨论如何声明或访问多维数组之前,我将向你展示如何在内存中实现它们。首先,如何将一个多维对象存储到一维内存空间中?请先考虑一下这个形式的 Pascal 数组:

A:array[0..3,0..3] of char; 

这个数组包含 16 字节,组织成四行四列的字符。你必须以某种方式将这个数组中的 16 个字节与主存中的 16 个连续字节进行对应。图 4-2 展示了实现这一目标的一种方法。

图 4-2:将 4×4 数组映射到连续的内存位置

实际的映射方式并不重要,只要满足两个条件:(1)每个元素都映射到一个唯一的内存位置(数组中的两个条目不能占用相同的内存位置),以及(2)映射是一致的(数组中的某个特定元素总是映射到相同的内存位置)。因此,你需要一个具有两个输入参数(行和列)的函数,它能够生成一个偏移量,指向一个包含 16 个内存位置的线性数组。

任何满足这些约束的函数都能正常工作。事实上,你可以随机选择一个映射,只要它是一致的。然而,你实际上需要一个在运行时高效计算的映射,并且能够适应任意大小的数组(不仅仅是 4×4,甚至不限于二维数组)。虽然许多可能的函数都符合这个要求,但有两个特别常用,它们是大多数程序员和高级语言(HLL)使用的:行优先顺序和列优先顺序。

4.7.4.1 行优先顺序

行优先顺序将连续的元素按行依次排列,然后向下排列列,并将这些元素映射到连续的内存位置。图 4-3 展示了这种映射方式。

图 4-3:数组元素的行优先顺序

行优先顺序是大多数高级语言(HLL)使用的方法。它在机器语言中容易实现和使用:你从第一行(行 0)开始,然后将第二行连接到第一行的末尾。接着将第三行连接到列表的末尾,再将第四行连接上,依此类推(见图 4-4)。

图 4-4:4×4 数组的行优先顺序的另一种视图

将索引值列表转换为偏移量的函数是对计算单维数组元素地址公式的轻微修改。计算二维行主序数组偏移量的公式如下:

`Element_Address` =
    `Base_Address` +
      (`colindex` × `row_size` + `rowindex`) × `Element_Size` 

如常,Base_Address 是数组第一个元素的地址(在这个例子中是 A[0][0]),Element_Size 是数组单个元素的大小,单位是字节。colindex 是最左边的索引,rowindex 是数组中的最右边的索引。row_size 是数组中一行的元素数量(在此例中为 4,因为每行有四个元素)。假设 Element_Size 为 1,这个公式计算出从基地址开始的以下偏移量:

Column          Row             Offset 
Index                           into Array 
0               0               0 
0               1               1 
0               2               2 
0               3               3 
1               0               4 
1               1               5 
1               2               6 
1               3               7 
2               0               8 
2               1               9 
2               2               10 
2               3               11 
3               0               12 
3               1               13 
3               2               14 
3               3               15 

对于一个三维数组,计算内存偏移量的公式如下:

`Address` =
    `Base` + ((`depthindex` × `col_size` + `colindex`) × `row_size` +
             `rowindex`) × `Element_Size` 

col_size 是列中项目的数量,row_size 是行中项目的数量。

在 C/C++ 中,如果你声明数组为

`type` A[i][j][k]; 

那么 row_size 等于 k,col_size 等于 j。

对于一个四维数组,在 C/C++ 中声明为

`type` A[i][j][k][m]; 

计算数组元素地址的公式如下所示:

`Address` =
    `Base` +
      (((`LeftIndex` × `depth_size` + `depthindex`) × `col_size` +
         `colindex`) × `row_size` + `rowindex`) × `Element_Size` 

depth_size 等于 j,col_size 等于 k,row_size 等于 m。LeftIndex 表示最左边索引的值。

到现在为止,你可能已经开始看到一个模式了。一个通用的公式可以计算 任何 维度数组的内存偏移量;然而,你很少会使用超过四维的数组。

另一种方便的思考行主序数组的方式是将其视为数组的数组。考虑以下单维 Pascal 数组定义:

A: array [0..3] of `sometype`; 

其中 sometype 是类型 sometype = array [0..3] of char; 且 A 是一个单维数组。它的各个元素恰好是数组,但目前你可以暂时忽略这一点。

这是计算单维数组中元素地址的公式:

`Element_Address` = `Base` + `Index` × `Element_Size` 

在这种情况下,Element_Size 恰好为 4,因为 A 中的每个元素是由四个字符组成的数组。因此,这个公式计算出这个 4×4 字符数组中每一行的基地址(见 图 4-5)。

图 4-5:将一个 4×4 数组视为数组的数组

当然,一旦你计算出一行的基地址,你可以重新应用单维数组的公式来获得特定元素的地址。虽然这不会影响计算,但处理多个单维计算可能比处理复杂的多维数组计算要容易一些。

考虑如下定义的 Pascal 数组:

A:array [0..3, 0..3, 0..3, 0..3, 0..3] of char; 

你可以将这个五维数组看作是一个单维数组的数组。以下是一个 Pascal 代码提供的定义:

type 
 OneD = array[0..3] of char; 
 TwoD = array[0..3] of OneD; 
 ThreeD = array[0..3] of TwoD; 
 FourD = array[0..3] of ThreeD; 
var 
 A: array[0..3] of FourD; 

OneD 的大小是 4 字节。因为 TwoD 包含四个 OneD 数组,所以它的大小为 16 字节。同样,ThreeD 是四个 TwoD 数组,因此它的大小为 64 字节。最后,FourD 是四个 ThreeD 数组,因此它的大小为 256 字节。要计算 A[b, c, d, e, f] 的地址,可以按照以下步骤进行:

计算 A[b] 的地址,公式为 Base + b × size。此处 size 为 256 字节。将此结果作为下一步计算的基地址。

计算 A[b, c] 的地址,使用公式 Base + c × size,其中 Base 是前一步得到的值,size 为 64。将结果作为下一步计算的基地址。

计算 A[b, c, d] 的基地址,使用公式 Base + d × size,其中 Base 来自前一步的计算,size 为 16。将此结果作为下一步计算的基地址。

计算 A[b, c, d, e] 的地址,使用公式 Base + e × size,其中 Base 来自前一步的计算,size 为 4。将此值作为下一步计算的基地址。

最后,使用公式 Base + f × size 计算 A[b, c, d, e, f] 的地址,其中 Base 来自前一步的计算,size 为 1(显然,你可以忽略这个最后的乘法)。此时得到的结果就是所需元素的地址。

你不会在汇编语言中找到更高维数组的主要原因之一是,汇编语言强调了这种访问方式的低效性。像 A[b, c, d, e, f] 这样的表达式在 Pascal 程序中很容易出现,而你可能没有意识到编译器在做什么。汇编语言程序员不会这么随意——他们看得到使用高维数组时所面临的混乱。事实上,好的汇编语言程序员尽量避免使用二维数组,并且在使用此类数组时,如果确实必须使用,它们往往会采取一些技巧来访问数据。

4.7.4.2 列主序列

列主序列是高级语言常用来计算数组元素地址的另一种方法。FORTRAN 和各种 BASIC 方言(例如旧版本的 Microsoft BASIC)使用这种方法。

在行主序列中,最右边的索引随着你在连续的内存位置中移动而增加得最快;而在列主序列中,最左边的索引增加得最快。从图示上看,列主序列的数组组织如 图 4-6 所示。

图 4-6:数组元素的列主序列排列

使用列主序列时,计算数组元素地址的公式与行主序列类似。你只需要将计算中的索引和大小反转。

对于二维列主序列数组:

`Element_Address` = `Base_Address` +
                (`rowindex` × `col_size` + `colindex`) × `Element_Size` 

对于三维列主序列数组:

`Address` = `Base` + ((`rowindex` × `col_size` + `colindex`) ×
        `depth_size` + `depthindex`) × `Element_Size` 

对于四维列主序列数组:

`Address` =
    `Base` + (((`rowindex` × `col_size` + `colindex`) × `depth_size` +
      `depthindex`) × `Left_size` + `Leftindex`) × `Element_Size` 

更高维数组的计算公式按类似的方式进行。

4.7.4.3 多维数组的存储分配

如果你有一个m×n的数组,它将包含m × n个元素,并需要m × n × Element_Size 字节的存储空间。要为数组分配存储空间,你必须预留这块内存。像往常一样,你可以通过多种方式完成这项任务。在 Gas 中,声明多维数组最常见的方法是使用.space 指令:

`ArrayName`: .space `size`1 * `size`2 * `size`3 * ... * `size`n * (`Element_Size`) 

这里,size1 到 sizen 是数组各维度的大小,(Element_Size) 是单个元素的大小(以字节为单位)。我建议在这个表达式的 Element_Size 部分加上括号,以强调它不是多维数组中的另一个维度。

例如,这里是一个 4×4 字符数组的声明:

GameGrid: .space 4 * 4 // `Element_Size` is 1\. 

这是另一个示例,展示如何声明一个三维字符串数组(假设该数组存储 64 位指针指向字符串):

NameItems: .space 2 * 3 * 3 * (8)  // dword NameItems[2, 3, 3] 

与一维数组类似,你可以通过在声明后跟上数组常量的值来初始化数组的每个元素。数组常量忽略维度信息;唯一重要的是数组常量中的元素数量与实际数组中的元素数量相匹配。以下示例展示了带有初始化器的 GameGrid 声明:

GameGrid: .byte 'a', 'b', 'c', 'd' 
          .byte 'e', 'f', 'g', 'h' 
          .byte 'i', 'j', 'k', 'l' 
          .byte 'm', 'n', 'o', 'p' 

这个示例的布局旨在增强可读性。Gas 并不会将这四行分开的代码解释为数组中的数据行;人类会这样理解,这也是为什么以这种方式编写数据更好的原因。如果你有一个大型数组,或者数组的行非常大,或者数组有多个维度,那么几乎不可能得到可读的代码;在这种情况下,仔细解释每个细节的注释会非常有用。

使用常量表达式来计算数组元素的数量,而不是直接使用常量 16(4 × 4),更清楚地表明这段代码是在初始化一个 4×4 元素的数组,而不仅仅是一个简单的字面常量 16。

4.7.4.4 如何访问多维数组的元素

要访问多维数组的元素,你需要能够进行两个值的乘法运算;这可以通过使用 mul(乘法)和 madd(乘法加法)指令来实现。

mul 和 madd 指令具有以下语法:

mul  `reg`d`, reg`1`, reg`r  // `reg`d = `reg`l * `reg`r 
madd `reg`d`, reg`l`, reg`r`, reg`a   // `reg`d = `reg`l * `reg`r + `reg`a 

其中,regd 是目标寄存器(32 位或 64 位),regl 和 regr 是源寄存器(左操作数和右操作数),而 rega 是第三个源操作数。这些指令执行注释中描述的计算。

这些指令没有带有“s”后缀的形式,因此在执行后不会更新标志位。一个n位 × n位的乘法可以产生一个 2 × n位的结果;然而,这些指令只在目标寄存器中保留n位,任何溢出都会丢失。遗憾的是,这些指令不允许使用立即数操作数,尽管这会很有用。

multiply 指令有几个其他变种,用于其他目的。这些内容在第六章中有详细介绍。

现在你已经看过计算多维数组元素地址的公式,是时候看看如何使用汇编语言访问这些数组的元素了。ldr、lsl 和 mul/madd 指令可以轻松处理计算多维数组偏移量的各种方程。首先,考虑一个二维数组:

 .data 
i:       .word  .-. 
j:       .word  .-. 
TwoD:    .word  4 * 8  * (4) 
           . 
           . 
           . 
// To perform the operation TwoD[i,j] := 5; 
// you'd use code like the following. 
// Note that the array index computation is (i * 4 + j) * 4\. 

         lea x0, i 
         ldr w0, [x0]    // Clears HO bits of X0 
         lsl x0, x0, #2  // Multiply i by 4\. 
         lea x1, j 
         ldr w1, [x1] 
         add w0, w0, w1  // W0 = i * 4 + j 
         lea x1, TwoD    // X1 = base 
         mov w2, #5      // [TwoD + (i * 4 + j) * 4] = 5 
         str w2, [x1, x0, lsl #2] // Scaled by 4 (element size) 

现在考虑一个使用三维数组的第二个例子:

 .data 
i:      .word  .-. 
j:      .word  .-. 
k:      .word  .-. 
ThreeD: .space 3 * 4 * 5 * (4) // word ThreeD[3, 4, 5] 
          . 
          . 
          . 
// To perform the operation ThreeD[i,j,k] := W7; 
// you'd use the following code that computes 
// ((i * 4 + j) * 5 + k) * 4 as the address of ThreeD[i,j,k]. 

          lea  x0, i 
          ldr  w0, [x0] 
          lsl  w0, w0, #2  // Four elements per column 
          lea  x1, j       // Add in j. 
          ldr  w1, [x1] 
          add  w0, w0, w1 
          mov  w1, #5      // Five elements per row 
          lea  x2, k 
          ldr  w2, [x2] 
          madd w0, w0, w1, w2 // ((i * 4 + j) * 5 + k) 
          lea  x1, ThreeD 
          str  w7, [x1, w0, uxtw #2] // ThreeD[i,j,k] = W7 

这段代码使用 madd 指令将 W0 中的值乘以 5,并同时加上 k 索引。因为 lsl 指令只能将寄存器的值乘以 2 的幂,所以我们必须在这里进行乘法运算。虽然也有方法可以将寄存器中的值乘以 2 以外的常数,但 madd 指令更方便,特别是它还可以同时处理加法操作。

4.8 结构体

另一个重要的复合数据结构是 Pascal 的 record 或 C/C++/C# 的 struct。Pascal 的术语可能更好,因为它避免了与更通用的术语 数据结构 的混淆。然而,本书使用 struct 这个术语,因为基于 C 的语言现在更为常用。(在其他语言中,记录和结构也有其他名称,但大多数人至少能认出其中之一。)

而数组是同质的,所有元素都是相同类型,结构体中的元素可以具有不同类型。数组通过整数索引让你选择特定的元素。而在结构体中,你必须通过偏移量(相对于结构体的开始)选择一个元素,称为 字段

结构体的主要目的是让你将不同但在逻辑上相关的数据封装到一个单独的包中。Pascal 记录声明一个假设的学生数据结构是一个典型的例子:

student =
     record 
          sName:    string[64]; 
          Major:    integer; 
          SSN:      string[11]; 
          Midterm1: integer; 
          Midterm2: integer; 
          Final:    integer; 
          Homework: integer; 
          Projects: integer; 
     end; 

大多数 Pascal 编译器会将记录中的每个字段分配到连续的内存位置。这意味着 Pascal 会为名称保留前 65 字节,接下来的 2 字节存储 Major 代码(假设为 16 位整数),接下来的 12 字节存储社会安全号码,依此类推。(字符串需要额外的一个字节,用于编码字符串的长度,除了所有字符外。)John 变量声明会在内存中分配 89 字节存储空间,如 图 4-7 所示(假设没有对字段进行填充或对齐)。

图 4-7:内存中的学生数据结构

如果标签 John 对应于该记录的基地址,则 sName 字段位于偏移量 John + 0,Major 字段位于偏移量 John + 65,SSN 字段位于偏移量 John + 67,以此类推。在汇编语言中,如果 X0 保存了 John 结构的基地址,可以通过以下指令访问 Major 字段:

ldrh  w0, [x0, #65] 

这将 W0 加载为位于 John + 65 地址的 16 位值。

4.8.1 处理结构体的有限气体支持

不幸的是,Gas 仅通过.struct指令提供对结构的极少支持(请参阅“Linux .struct Directive” 第 217 页)。更不幸的是,macOS 汇编器不支持.struct

在 macOS 和 Linux 下使用结构,你需要一种方法来指定结构的所有字段的偏移量,以便在寄存器间接加偏移地址模式中使用(例如前一节最后一个示例行中)。理论上,你可以手动使用等式来定义所有的偏移量:

.equ sName, 0 
.equ Major, 65 
.equ SSN, 67 
.equ Mid1, 79 
.equ Mid2, 81 
.equ Final, 83 
.equ Homework, 85 
.equ Projects, 87 

然而,这种方法绝对是可怕的、容易出错的,而且难以维护。理想的方法是提供一个结构名称(类型名称)和字段名称及其类型的列表。从这些信息中,你可以得到所有字段的偏移量,以及整个结构的大小(你可以使用.space指令为结构分配存储空间)。

aoaa.inc 包含多个宏定义,可以帮助你在汇编语言源文件中声明和使用结构。这些宏并不是非常强大,但是在小心使用时,它们能完成任务。表 4-1 列出了这些宏及其参数。字段名称在整个程序中必须是唯一的,不仅仅在结构定义中。另外,请注意,struct/ends 宏不支持嵌套。

表 4-1:aoaa.inc 宏定义结构

Macro Argument(s) 描述
struct name, offset 开始一个结构定义。offset 字段是可选的,可以是一个(较小的)负数或 0。默认(也是最常用的)值为 0。
ends name 结束结构定义。name 参数必须与 struct 调用中提供的名称匹配。
byte name, elements 创建一个 byte 类型的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
hword name, elements 创建一个 hword 类型的字段。name 是(唯一的)字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
word name, elements 创建一个 word 类型的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
dword name, elements 创建一个 dword 类型的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
qword name, elements 创建一个 qword 类型的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
single name, elements 创建一个 single 类型的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。
double name, elements 创建一个类型为 double 的字段。name 是唯一的字段名称。elements 是可选的(默认值为 1),指定数组元素的数量。

对于字符串,你可以指定一个 dword 字段(用于保存字段的指针),或者指定一个足够数量的字节字段来保存字符串中的所有字符。

上一节中的学生示例可以按如下方式编码:

struct student 
    byte  sName, 65 // Includes zero-terminating byte 
    hword Major 
    byte  SSN, 12   // Includes zero-terminating byte 
    hword Midterm1 
    hword Midterm2 
    hword Final 
    hword Homework 
    hword Projects 
ends student 

你可以像这样声明一个类型为 student 的变量:

student John 

ends 宏会自动生成一个与结构名称相同的宏,因此你可以像使用指令一样使用它,为结构类型的实例分配足够的空间。

你可以按如下方式访问 John 的字段:

lea  x0, John 
ldrh w1, [x0, #Midterm1] 
ldrh w2, [x0, #Midterm2] 
ldrh w3, [x0, #Final]   // And so on ... 

这个宏包有几个问题。首先,字段名称必须在整个汇编语言源文件中唯一(与标准结构不同,标准结构中的字段名称仅对结构本身有效)。因此,这些结构往往会受到命名空间污染的影响,这种情况发生在你尝试将一些字段名用于其他目的时。例如,sName 很可能会在源文件的其他地方再次使用,因为它是一个常见的标识符。解决这个问题的快速且粗略的办法是始终在字段名前加上结构名称和一个句点。例如:

struct student 
    byte  student.sName, 65 
    hword student.Major 
 byte  student.SSN, 12 
    hword student.Midterm1 
    hword student.Midterm2 
    hword student.Final 
    hword student.Homework 
    hword student.Projects 
ends student 

这需要更多的输入,但大多数时候它能解决命名空间污染的问题。

请考虑本节中给出的学生 John 宏调用/声明。此宏扩展为

John:  .fill student.size 

其中 student.size 是 struct 宏生成的额外字段,指定结构的总大小(以字节为单位)。

struct 宏接受第二个(可选)参数:结构中字段的起始偏移量。默认情况下,这个值为 0。如果你在此处提供一个负数,struct 生成的指令/宏将有所不同。请考虑以下结构定义:

struct HLAstring, -4 
word   HLAstring.len 
byte   HLAstring.chars, 256 
ends   HLAstring 

HLA 字符串实际上与此处提供的结构略有不同,但这确实是负起始偏移的一个很好的示例。

struct 生成的 HLAstring 宏执行以下操作:

 HLAstring myString 
         // Expands to 
           .fill      4 
myString:  .fill      256 

这个扩展将 myString 标签放置在结构开始后的前 4 个字节处。这是因为 HLAstring.len 字段的偏移量为 -4,意味着长度字段从结构的基地址前 4 个字节开始(结构变量的名称总是与基地址相关联)。你将在下一章看到这个特性的一些重要用途。

struct 宏不允许正偏移量(大于 0 的值)。如果你指定正值,它将在汇编时生成错误。

struct 宏的一个问题是它没有提供初始化结构字段的方法。要了解如何做到这一点,请继续阅读。

4.8.2 初始化结构

结构体宏定义在编译时无法初始化结构体的字段。你必须在运行时分配值,或者使用 Gas 指令手动构建结构体变量。例如:

John:    .asciz  "John Somebody"   // sName 
         .space  65 - (.-John)     // Must be 65 bytes long! 
         .hword  0                 // Major 
         .asciz  "123-45-6578"     // SSN-Exactly 12 bytes long 
         .hword  75                // Midterm1 
         .hword  82                // Midterm2 
         .hword  90                // Final 
         .hword  72                // Homework 
         .hword  80                // Projects 

这将结构体的字段初始化为相应的值。

4.8.3 创建结构体数组

程序设计中的常见模式是创建结构体数组。为此,首先创建一个结构体类型,并在声明数组变量时将其大小乘以数组元素的个数,如下例所示:

numStudents = 30 
   . 
   . 
   . 
Class:  .fill student.size * numStudents 

要访问该数组的元素,可以使用标准的数组索引技术。由于类是一个一维数组,你可以通过以下公式计算该数组元素的地址:baseAddress + index × student.size。例如,要访问类中的一个元素,你可以使用如下代码:

// Access field Final, of element i of class: 
// X1 := i * student.size + offset Final 

    lea  x1, i 
    ldr  x1, [x1] 
    mov  x2, #student.size 
    mov  x3, #student.Final 
    madd x1, x1, x2, x3   // Include offset to field. 
    lea  x2, class 
    ldrh w0, [x2, x1]     // Accesses class[i].Final 

你必须在要访问的字段的偏移量中加上偏移量。不幸的是,缩放索引寻址模式在寻址模式中没有包括偏移量组件,但 madd 通过将这一加法作为乘法的一部分,为我们节省了一条指令。

自然,你也可以创建结构体的多维数组,使用行主序或列主序函数来计算该结构体中元素的地址。唯一的变化是每个元素的大小是结构体对象的大小:

 .data 

numStudents = 30 
numClasses  = 2 

// student Instructor[numClasses][numStudents] 

Instructor:   .fill  numStudents * numClasses * (student.size) 
whichClass:   .dword 1 
whichStudent: .dword 10 
          . 
          . 
          . 
// Access element [whichClass,whichStudent] of class 
// and load Major into W0: 

     lea  x0, whichClass 
     ldr  x1, [x0] 
     mov  x2, #numStudents    // X1 = whichClass * numStudents 
     mul  x1, x1, x2 
     lea  x0, whichStudent 
     ldr  x2, [x0]            // X1 = (whichClass * numStudents +
     add  x1, x1, x2          //  numStudents) 
     mov  x2, #student.size   //   * sizeStudent + offset Major 
     mov  x3, #Major 
     madd x1, x1, x2, x3 

     lea  x0, Instructor // W0 =  Instructor[whichClass] 
     ldrh w0, [x0, x1]   //         [whichStudent].Major 

这演示了如何访问结构体数组的字段。

4.8.4 对齐结构体中的字段

为了在程序中实现最佳性能,或者确保 Gas 结构体正确映射到高级语言中的记录或结构体,你通常需要能够控制结构体内字段的对齐方式。例如,你可能希望确保双字字段的偏移量是 4 的倍数。你可以使用 salign 宏来实现这一点。以下代码创建了一个字段对齐的结构体:

struct tst 
byte bb 
salign 2   // Aligns offset to next 4-byte boundary 
byte c 
ends tst 

至于.align 指令,salign 宏将结构体的偏移量对齐到 2^n,其中n是 salign 参数中指定的值。在这个例子中,c 的偏移量被设置为 4(该宏将字段偏移从 1 向上调整为 4)。

字段对齐由你在创建结构体变量时决定。然而,如果你要与一个使用结构体的高级语言(HLL)中的代码进行链接,你需要确定该语言特定的字段对齐方式。大多数现代高级语言使用自然对齐:字段会对齐到与该字段大小(或该字段元素的大小)相同的边界。结构体本身会在地址上对齐到最大对象的大小。有关详细信息,请参阅第 4.11 节,“更多信息”,在第 221 页上查看相关链接。

4.9 共用体

共用体(如 C/C++中的共用体)与结构体类似,它们创建一个包含多个字段的聚合数据类型。然而,与结构体不同的是,共用体的所有字段都占据数据结构中的相同偏移量。

程序员通常使用联合体有两个原因:节省内存或创建别名。节省内存是该数据结构功能的预期用途。为了理解它是如何工作的,考虑以下结构体类型:

struct numericRec 
       word  i 
       word  u 
       dword q 
ends   numericRec 

如果你声明一个类型为 numericRec 的变量,例如 n,你可以通过 n.i、n.u 和 n.q 访问各个字段。结构体会为每个字段分配不同的偏移量,有效地为每个字段分配独立的存储空间。而联合体则为这些字段分配相同的偏移量(通常为 0),为每个字段分配相同的存储空间。

对于结构体而言,numericRec.size 的大小是 16,因为结构体包含两个字字段和一个双字字段。然而,相应的联合体的大小将是 8。这是因为联合体的所有字段占用相同的内存位置,而联合体对象的大小是该对象最大字段的大小(见图 4-8)。

图 4-8:联合体与结构体变量的布局

程序使用联合体有几个目的:节省内存、覆盖数据类型,以及创建变体类型(动态类型值,其类型可以在执行期间发生变化)。由于你可能不会在汇编语言程序中频繁使用联合体,因此我没有在aoaa.inc包含文件中创建联合体宏。然而,如果你真的需要联合体宏,你可以参考第十三章的信息,以及aoaa.inc中结构体宏的源代码,自己编写一个。

4.10 继续前进

本章结束了本书中关于计算机组织部分的内容,涉及了内存、常量、数据和数据类型的组织。讨论了内存变量和数据类型、数组、行主序和列主序、结构体和联合体,以及字符串,包括零终止、长度前缀和描述符字符串。还讨论了使用指针时可能遇到的问题,包括未初始化的指针、非法指针值、悬空指针、内存泄漏和类型不安全访问。

现在是时候开始认真学习汇编语言编程了。本书的下一部分将开始讨论过程和函数(第五章)、算术运算(第六章)、低级控制结构(第七章)和高级算术运算(第八章)。

4.11 更多信息

第二部分 基础汇编语言

第五章:5 过程

在过程式编程语言中,代码的基本单元是过程。过程是一组指令,用来计算一个值或执行一个动作,例如打印或读取字符值。本章讨论了 Gas 如何实现过程、参数和局部变量。到本章结束时,你应该能够熟练地编写自己的过程和函数。你还将完全理解参数传递和 ARM 应用程序二进制接口(ABI)调用约定。

本章涵盖了以下几个主题:

  • 汇编语言编程风格介绍,以及一些aoaa.inc宏,以提高程序的可读性

  • Gas 过程/函数及其实现(包括使用 bl、br 和 ret 指令),以及更多aoaa.inc宏,允许在源文件中更好地声明过程

  • 激活记录、自动变量、局部符号、寄存器保存和 ARM 堆栈

  • 向过程传递参数的各种方式,包括按值传递和按引用传递,以及如何使用过程指针和过程参数

本章还讨论了如何将函数结果返回给调用者,以及如何调用和使用递归函数。

5.1 汇编语言编程风格

直到本章为止,我没有强调良好的汇编语言编程风格,原因有两个。首先,本书假设你已经通过对高级语言(HLL)的经验了解了良好编程风格的重要性。其次,到目前为止引用的程序相对简单,编程风格在简单代码中并不重要。然而,随着你开始编写更复杂的 ARM 汇编语言程序,风格变得更加重要。

正如你现在可能已经能察觉到的,ARM 汇编语言代码的可读性远不如用 C/C++、Java 或 Swift 等高级语言编写的代码。因此,作为汇编语言程序员,你必须付出额外的努力,编写尽可能可读和易于维护的汇编代码。正如我指出的,GNU 汇编器(Gas)并不是作为一个供汇编语言程序员使用的工具,而是作为 GCC 编译器的后端,用来处理编译器的输出。由于这一点,以及 Gas 试图吸收尽可能多的来自众多汇编语言(不仅仅是 ARM 的汇编语言)特性,使用 Gas 编写高质量代码是一项困难的任务。

幸运的是,你可以使用 Gas 的宏处理器(以及利用 CPP 的能力)来对 Gas 汇编语言进行一些修改,从而访问一些能帮助你改善编程风格的功能。aoaa.inc 包含了相当数量的预定义宏和符号定义,帮助实现这一目标。第十三章逐行介绍了 aoaa.inc 的内容,并解释了如何使用这些宏以及如何创建你自己的宏,以提高 ARM 汇编语言程序的可读性。

当你编写汇编语言源文件时,可以随意将 aoaa.inc 包含在代码中,或将代码中的任何功能整合进你的汇编语言源文件。即使你不需要 aoaa.inc 提供的跨平台可移植性,它的宏和其他定义仍然可以帮助你编写更具可读性和可维护性的代码。aoaa.inc 头文件是开源的,并且遵循 Creative Commons 4.0 Attribution 许可协议(详见第 290 页的第 5.12 节,“更多信息”)。

作为使用宏使代码更具可读性的示例,考虑一下 aoaa.inc 中的 .code 宏。它展开成以下两个语句:

.text 
.align 2 

一般来说,你应始终确保 .text 段对齐在字边界(如果在前面的代码段中声明了一些长度不是 4 的倍数的数据,代码可能会出现错位)。始终对齐 .text 段是良好的编程风格,这样可以确保指令从正确的地址开始。为了避免代码中充斥着大量多余的 .align 指令,我建议使用 .code 指令来自动处理对齐。减少杂乱的代码使得你的代码更容易阅读。

aoaa.inc 头文件包含了几个额外的宏,接下来我将在本章的其余部分介绍,这些宏将 1960 年代风格的 Gas 语法与更现代的汇编器(如 Microsoft 宏汇编器 MASM 和针对 x86 处理器系列的 HLA 汇编器)中的功能相结合。使用这些功能(如正式的过程声明和局部变量声明)可以帮助你编写更易于阅读的汇编语言源代码。

即使在编写传统的汇编语言源代码时,你也可以遵循一些规则来提高代码的可读性。在本书中,我通常将汇编语言语句组织如下(大括号表示可选项,实际源代码中并不出现):

{`Label`:}   {{`instruction`}     `operands`}  {// `Comment`}

一般而言,我尽量将所有标签定义放在第一列,并将所有指令助记符对齐到第二列。我会尽量将操作数放在第三列。列之间的空格数量并不重要,但要确保助记符通常会对齐在同一列,操作数则倾向于从下一列开始。这是传统的汇编语言编程风格,也是大多数汇编语言程序员在阅读你的代码时希望看到的格式。

注意

由于格式化原因,本书常常压缩列之间的空白,并且有时会变化每列在同一列表中的位置。这是为了确保源代码行能在书中保持在一行内。在正常的源文件中,你应该尽量保持所有列对齐(第二列使用两个 4 字符的制表符位置,第三列大约在字符位置 16,依此类推)。

一般来说,不要像在高级语言中那样对语句进行缩进。汇编语言不是块结构化语言,不适合采用在块结构语言中有效的缩进技巧。如果需要将一系列语句区分开,最好的方法是插入两行或更多的空行在这些语句前后。注释也很有用,可以帮助区分两个独立的、松散耦合的代码块。

Gas 通常期望一个完整的汇编语言指令出现在源代码的一行中。理论上,你可以在换行符前使用反斜杠字符,将单条语句拆分成两行:

b.al   \
  targetLabel 

然而,这几乎从来没有合理的理由去这么做。除非有非常好的理由将指令分成多行,否则应该将指令保持在同一行(例如,如果源代码行因为某些原因变得异常长,尽管这种情况很少见)。标签字段是这个规则的一个例外:即使标签与程序中的下一条机器指令相关联,也可以单独占一行。

Gas(在 Linux 下)允许将多个汇编语言指令放在同一行,用分号分隔。然而,将多条语句放在同一行的做法,在汇编语言中比在高级语言中更不推荐——不要这么做。无论如何,macOS 汇编器不支持这一特性。

在解决了一些汇编语言风格的基本指南之后,现在是时候考虑本章的主要主题:汇编语言中的过程(函数)。

5.2 Gas 过程

大多数过程式编程语言通过调用/返回机制来实现过程。代码调用过程,过程执行其编写的操作,然后返回给调用者。调用和返回操作提供了 ARM 的过程调用机制。调用代码通过 bl 指令调用过程,过程通过 ret 指令返回给调用者。例如,以下 ARM 指令调用了 C 标准库中的 printf() 函数:

bl printf 

可惜,C 标准库并没有提供你所需要的所有例程。大多数情况下,你需要编写自己的 Gas 过程。一个基本的 Gas 过程声明格式如下:

`procName`: 
 `Procedure statements` 
    ret 

从技术上讲,过程并不需要以 ret 指令结束;ret 可以位于过程的中间,最后以 b.al 指令结尾。然而,作为一种良好的编程风格,建议使用 ret 指令(或等效的指令)作为过程体的最后一条指令。

过程声明出现在程序的 .text 段中。在上述语法示例中,procName 表示你希望定义的过程名称。这个名称可以是任何有效的(且唯一的)Gas 标识符。

下面是一个 Gas 过程声明的具体示例。这个过程在进入时会将 0 存储到 X0 所指向的 256 个字中:

zeroBytes: 
          mov  x1, #256*4     // 1,024 bytes = 256 words 
repeatlp: subs x1, x1, #4 
          str  wzr, [x0, x1]  // Store *after* subtraction! 
          bne  repeatlp       // Repeat while X1 >= 0\. 
          ret 

正如你可能已经注意到的,这个简单的过程没有涉及那些“魔法”指令,这些指令用于在 SP 寄存器上加减值。当过程会调用其他 C/C++ 代码(或其他符合 ARM ABI 的语言编写的代码)时,这些指令是 ARM ABI 的要求。由于这个小函数并不调用其他过程,因此它不执行这些代码。

还需要注意的是,这段代码使用循环索引从 1,024 递减到 0,每次递减 4,并倒序(从后到前)填充 256 字的数组,而不是从前到后填充。这是汇编语言中的常见技巧。最后,这段代码在将 0 存储到内存之前会将 X1 减少 4。原因是循环索引(X1)初始化时就在 X0 所指向的数组末尾之外。str 指令不会影响标志,因此 bne 指令会响应 subs 指令设置的标志。

你可以使用 ARM 的 bl 指令来调用这个过程。当程序执行时,代码跳转到 ret 指令,过程返回到调用它的地方,并开始执行 bl 指令之后的第一条指令。清单 5-1 提供了一个调用 zeroBytes 例程的示例。

// Listing5-1.S 
//
// Simple procedure call example 

#include "aoaa.inc"

stackSpace  =       64 
saveLR      =       56 

            .section .rodata, ""
ttlStr:     .asciz   "Listing 5-1"

 .data 
wArray:     .space   256 * (4), 0xff // Fill with 0xFF. 

            .text 
            .align   2 

// getTitle 
//
// Return program title to C++ program: 

            .global getTitle 
getTitle: 
            lea     x0, ttlStr 
            ret 

// zeroBytes 
//
// Here is the user-written procedure 
// that zeros out a 256-word buffer. 
// On entry, X0 contains the address 
// of the buffer. 

zeroBytes: 
            mov     x1, #256 * 4 
repeatlp:   subs    x1, x1, #4 
            str     wzr, [x0, x1] // Store *after* subtraction! 
            bne     repeatlp      // Repeat while X1 != 0\. 
            ret 

// Here is the asmMain function: 

            .global asmMain 
asmMain: 
            sub     sp, sp, #stackSpace // Reserve stack storage. 
            str     lr, [sp, #saveLR] 

            lea     x0, wArray 
            bl      zeroBytes 

            ldr     lr, [sp, #saveLR]   // Restore return address. 
            add     sp, sp, #stackSpace // Clean up stack. 
            ret     // Returns to caller 

我不会涉及构建或运行命令,因为这个程序除了显示它已运行并终止之外,不会产生任何实际输出。

Gas 语言并没有我们所说的程序组件的语法概念,像过程(或函数)这样的结构。它有可以用bl指令调用的标签,以及可以用ret指令返回的过程。然而,它没有可以用来在汇编源文件中区分一个过程与另一个过程的语法实体。

到目前为止,本书中的几个过程通过使用标签和返回语句来区分过程中的代码。例如,以下过程以zeroBytes开始,并以ret结束:

zeroBytes: 
          mov  x1, #256 * 4 
repeatlp: subs x1, x1, #4 
          str  wzr, [x0, x1] // Store *after* subtraction! 
          bge  repeatlp      // Repeat while X1 >= 0\. 
          ret 

在过程之前立即添加注释可能有助于将其与前面的代码区分开。然而,阅读代码的人需要努力区分zeroBytes标签和repeatlp标签。实际上,完全可以使用这两个标签作为过程的入口点(zeroBytes总是从传入的 X0 地址开始清零 256 个字,而repeatlp则会清零 X1/4 指定数量的字)。当然,过程并不一定只能使用单一的ret指令(或者根本不使用,因为还有其他方式可以从过程返回)。因此,依赖一个语句标签和ret指令来界定过程并不总是合适的。

尽管在你的 Gas 过程的开始和结束处添加注释以澄清发生了什么总是一个好主意,但解决这个问题的最佳方式是使用语法糖——这些语句可以澄清含义,而不会生成任何代码——来界定过程。虽然 Gas 没有提供这样的语句,但你可以为相同的目的编写自己的宏。*aoaa.inc*包含文件提供了几个这样的宏:procendp。以下是它们的语法:

proc `procedureName` {, public}  // Braces denote optional item. 

     `Body of the procedure` 

endp `procedureName` 

这里,procedureName将是过程的名称,你必须在procendp语句中提供相同的名称。, public参数是可选的,正如元符号大括号所表示的。如果提供了public参数,proc宏将自动为过程生成.global指令。

这是一个使用procendp宏与getTitle函数的非常简单的示例:

proc    getTitle, public 
lea     x0, ttlStr 
ret 
endp    getTitle 

这些宏为getTitle过程生成了常见的语句:

 .global     getTitle  // Generated by public 
getTitle:    // Generated by proc 
             lea         x0, ttlStr 
             ret 

endp宏在程序中并不会生成任何内容。它仅仅是检查传递的标识符,以确保它与proc宏调用中的过程名称相匹配。

因为procendp语句可以整洁地将一个过程的主体与程序中的其他代码隔离开来,从这一点开始,本书将使用这些语句定义过程。我建议你也利用这些宏来帮助使你自己未来的过程更加易读。

高级语言中的过程和函数提供了本地符号这一有用的特性。下一节将介绍 Gas 支持的有限形式的本地标签。

5.2.1 Gas 本地标签

与高级语言(HLL)不同,Gas 不支持 词法作用域符号。你在一个程序中定义的标签,并不局限于该程序的作用域。除了一个特殊情况外,你在 Gas 程序中定义的符号,包括用 proc/endp 定义的符号,都是在整个源文件中可见的。

然而,Gas 确实支持一种有限形式的 本地标签,它由一个数字字符和一个冒号组成(0: 到 9:)。在代码中,使用 Nb 或 Nf 来引用这些符号,其中 N 是数字(0 到 9)。形式为 Nb 的符号引用源文件中前一个 N: 标签(b 代表 后退)。形式为 Nf 的符号引用源文件中下一个 N: 标签(f 代表 前进)。

下面是一个 Gas 本地标签的示例,位于 zeroBytes 程序中(改写自前一节):

 proc zeroBytes 
          mov  x1, #256 * 4 
0:        subs x1, x1, #4 
          str  wzr, [x0, x1] // Store *after* subtraction! 
          bne  0b            // Repeat while X1 != 0\. 
          ret 
          endp zeroBytes 

本地标签在没有充分理由使用更有意义的名称时非常有用。然而,使用这些本地符号时要小心。当适量使用时,它们有助于减少程序中无意义标签的干扰,但使用过多会破坏程序的可读性(“这个代码跳转到哪个 0 标签?”)。

使用本地标签时,目标标签应仅位于几条指令之内;如果代码跳转的距离过远,你在以后优化代码时有可能会在源代码和目标标签之间插入相同的本地标签。这会产生不良后果,并且 Gas 不会提醒你错误。

5.2.2 bl、ret 和 br

一旦你可以声明一个程序,接下来的问题是如何调用(和返回)一个程序。如你在本书中多次看到的那样,你通过使用 bl 调用程序,并通过使用 ret 从程序中返回。本节将更详细地介绍这些指令(以及 br 指令),包括它们使用的效果。

ARM bl 指令做两件事:它将紧随 bl 指令后的(64 位)地址复制到 LR 寄存器中,然后将控制转移到指定程序的地址。bl 复制到 LR 的值被称为 返回地址

当一个程序希望返回调用者并继续执行 bl 指令之后的第一条语句时,通常通过执行 ret 指令返回给调用者。ret 指令通过间接转移控制到 LR 寄存器(X30)中保存的地址。

ARM ret 指令有两种形式

ret
ret `reg`64

其中 reg64 是 ARM 的三十二个 64 位寄存器之一。如果出现 64 位寄存器操作数,CPU 使用该寄存器中保存的地址作为返回地址;如果没有寄存器,默认使用 X30(LR)。

ret 指令实际上是 br(通过寄存器间接分支)指令的一个特殊情况。br 的语法是

br  `reg`64

其中 reg64 是 ARM 的三十二个 64 位寄存器之一。该指令还将控制权转移到指定寄存器中保存的地址。而 ret reg64 指令向 CPU 提供一个提示,表示这是一个实际的子程序返回,而 br reg64 指令则没有提供这样的提示。在某些情况下,如果给出提示,ARM 可以更快地执行代码。第七章 介绍了 br 指令的一些用法。

以下是一个最小化 Gas 过程的示例:

proc minimal
ret
endp minimal

如果你调用这个过程并使用 bl 指令,minimal 将简单地返回到调用者。如果你没有在过程中放置 ret 指令,当程序遇到 endp 语句时,它不会返回到调用者。相反,程序将继续执行紧跟在过程之后的代码。

示例 5-2 演示了这个问题。主程序调用了 noRet,它直接跳转到 followingProc(打印出 followingProc was called 消息)。

// Listing5-2.S
//
// A procedure without a ret instruction

#include "aoaa.inc"

stackSpace  =           64
saveLR      =           56

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 5-2"
fpMsg:      .asciz      "followingProc was called\n"

            .code
            .extern     printf

// Return program title to C++ program:

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// noRet
//
// Demonstrates what happens when a procedure
// does not have a return instruction

            proc    noRet
            endp    noRet

            proc    followingProc
            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]

            lea     x0, fpMsg
            bl      printf

            ldr     lr, [sp, #saveLR]
            add     sp, sp, #stackSpace
            ret
            endp    followingProc

// Here is the asmMain function:

            proc    asmMain, public
            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]

 bl      noRet

            ldr     lr, [sp, #saveLR]
            add     sp, sp, #stackSpace
            ret
            endp    asmMain

如你所见,noRet 中没有 ret 指令,因此当主程序(asmMain)调用 noRet 时,它将直接跳转到 followingProc。以下是构建命令和示例执行:

$ ./build Listing5-2
$ ./Listing5-2
Calling Listing5-2:
followingProc was called
Listing5-2 terminated

尽管在某些罕见情况下这种行为可能是理想的,但在大多数程序中通常表示一个缺陷。因此,请始终记得使用 ret 指令显式地从过程返回。

5.3 保存机器状态

示例 5-3 尝试打印 20 行,每行 40 个空格和一个星号。

// Listing5-3.S
//
// Preserving registers (failure) example

#include "aoaa.inc"

stackSpace  =           64
saveLR      =           56
saveX19     =           48

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 5-3"
space:      .asciz      " "
asterisk:   .asciz      "*, %d\n"

            .data
loopIndex:  .word       .-.     // Used to print loop index value

            .code
            .extern     printf

// getTitle
//
// Return program title to C++ program:

            proc    getTitle, public
            lea     x0, ttlStr
 ret
            endp    getTitle

// print40Spaces
//
// Prints out a sequence of 40 spaces
// to the console display

            proc    print40Spaces
            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]

            mov     w19, #40
printLoop:  lea     x0, space
            bl      printf
            subs    w19, w19, #1
            bne     printLoop // Until W19 == 0
            ldr     lr, [sp, #saveLR]
            add     sp, sp, #stackSpace
            ret
            endp    print40Spaces

// Here is the asmMain function:

            proc    asmMain, public

            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]   // Save return address.
            str     x19, [sp, #saveX19] // Must preserve nonvolatile register.

            mov     w19, #20
astLp:      bl      print40Spaces
            lea     x0, loopIndex
            str     w19, [x0]
            lea     x0, asterisk
            vparm2  loopIndex
            bl      printf
            subs    w19, w19, #1
            bne     astLp

            ldr     x19, [sp, #saveX19]
            ldr     lr, [sp, #saveLR]
            add     sp, sp, #stackSpace
            ret     // Returns to caller
            endp    asmMain

不幸的是,一个微妙的错误导致了无限循环。主程序使用 bne printLoop 指令创建一个循环,该循环调用 Print40Spaces 20 次。此函数使用 W19 来计数它打印的 40 个空格,然后返回时 W19 的值为 0。主程序打印一个星号和换行符,递减 W19,然后因为 W19 不为 0(此时它将始终为 -1)而继续重复。

这里的问题是 print40Spaces 子程序没有保存 W19 寄存器。保存寄存器意味着你在进入子程序时保存它,并在离开之前恢复它。如果 print40Spaces 子程序保存了 W19 寄存器的内容,那么示例 5-3 会正常运行。无需构建和运行该程序;它只是进入了一个无限循环。

请考虑以下的 print40Spaces 代码:

 proc    print40Spaces
            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]
            str     x19, [sp, #saveX19]

            mov     w19, #40
printLoop:  lea     x0, space
            bl      printf
            subs    w19, w19, #1
            bne     printLoop // Until W19 == 0
            ldr     lr, [sp, #saveLR]
            ldr     x19, [sp, #saveX19]
            add     sp, sp, #stackSpace
            ret
            endp    print40Spaces

这个变体的 print40Spaces 会在栈上保存并恢复 X19 寄存器以及 LR 寄存器。由于 X19 是一个非易失性寄存器(在 ARM ABI 中),所以它的保存责任由被调用者(即过程)承担。

请注意,print40Spaces 使用的是 X19 而不是 X0 到 X15 寄存器,特别是因为 X19 是非易失性的。printf() 函数不需要保存 X0 到 X15,因为它们在 ARM ABI 中是易失性寄存器。任何尝试使用这些寄存器的操作都可能失败,因为 printf() 不必保留它们的值。

一般来说,调用者(包含调用指令的代码)或被调用方(子程序)都可以负责保留寄存器。在遵循 ARM ABI 时,调用者负责保留易失性寄存器,而被调用方负责保留非易失性寄存器。当然,当你编写自己的子程序时,如果这些子程序不会被 ABI 兼容的函数调用,也不调用任何 ABI 兼容的函数,你可以选择任何你喜欢的寄存器保留方案。

清单 5-4 显示了清单 5-3 中程序的修正版本,该版本在调用 print40Spaces 时正确地保留了 X19 寄存器。

// Listing5-4.S
//
// Preserving registers (successful) example

#include "aoaa.inc"

stackSpace  =           64
saveLR      =           56
saveX19     =           48

 .section    .rodata, ""
ttlStr:     .asciz      "Listing 5-4"
space:      .asciz      " "
asterisk:   .asciz      "*, %d\n"

            .data
loopIndex:  .word       .-.     // Used to print loop index value

            .code
            .extern     printf

// Return program title to C++ program:

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// print40Spaces
//
// Prints out a sequence of 40 spaces
// to the console display

            proc    print40Spaces
            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]
            str     x19, [sp, #saveX19]

            mov     w19, #40
printLoop:  lea     x0, space
            bl      printf
            subs    w19, w19, #1
            bne     printLoop // Until W19 == 0
            ldr     lr, [sp, #saveLR]
            ldr     x19, [sp, #saveX19]
            add     sp, sp, #stackSpace
            ret
            endp    print40Spaces

// Here is the asmMain function:

            proc    asmMain, public

            sub     sp, sp, #stackSpace
            str     lr, [sp, #saveLR]   // Save return address.
            str     x19, [sp, #saveX19] // Must preserve nonvolatile register.

            mov     w19, #20
astLp:      bl      print40Spaces
            lea     x0, loopIndex
            str     w19, [x0]
            lea     x0, asterisk
            vparm2  loopIndex
            bl      printf
 subs    w19, w19, #1
            bne     astLp

            ldr     lr, [sp, #saveLR]
            ldr     x19, [sp, #saveX19]
            add     sp, sp, #stackSpace
            ret     // Returns to caller
            endp    asmMain

以下是清单 5-4 的构建命令和示例输出:

$ ./build Listing5-4
$ ./Listing5-4
Calling Listing5-4:
                                        *, 20
                                        *, 19
                                        *, 18
                                        *, 17
                                        *, 16
                                        *, 15
                                        *, 14
                                        *, 13
                                        *, 12
                                        *, 11
                                        *, 10
                                        *, 9
                                        *, 8
                                        *, 7
                                        *, 6
                                        *, 5
                                        *, 4
                                        *, 3
                                        *, 2
                                        *, 1
Listing5-4 terminated

如你所见,程序正确执行,没有进入无限循环。

被调用方保留寄存器有两个优点:节省空间和易于维护。如果被调用方(子程序)保留所有受影响的寄存器,那么只有一份 str 和 ldr 指令存在——即子程序中的指令。如果调用方保存寄存器中的值,那么程序在每次调用时需要一组保留指令。这不仅使程序更长,而且更难以维护。记住每次调用时哪些寄存器需要保存和恢复并不容易。

另一方面,如果一个子程序保留了它所修改的所有寄存器,它可能会不必要地保存一些寄存器。如果调用者已经保存了这些寄存器,那么子程序就不需要保存它不关心的寄存器。

保留寄存器的一个大问题是你的程序可能会随着时间的推移发生变化。你可能会修改调用代码或过程,以使用额外的寄存器。当然,这些变化可能会改变你必须保留的寄存器集合。更糟糕的是,如果修改发生在子程序本身,你将需要定位每一个调用该子程序的地方,并验证该子程序是否不会更改调用代码使用的任何寄存器。

汇编语言程序员通常在寄存器保留方面遵循一种常见约定:除非有充分的理由(性能问题)做出不同的处理,否则大多数程序员会保留每个子程序修改的寄存器(且该寄存器没有显式返回值)。这样可以减少程序出现缺陷的可能性,因为子程序修改了调用方期望被保留的寄存器。当然,你也可以遵循有关 ARM ABI 的规则,处理易失性和非易失性寄存器;然而,这种调用约定会对程序员和其他程序带来低效。虽然本书一般遵循 ARM ABI 的易失性和非易失性寄存器规则,但许多示例在一个过程里保留所有受影响的寄存器。

保留环境不仅仅是保留寄存器。你还可以保留子程序可能会改变的变量和其他值。

5.4 调用树、叶过程和堆栈

假设有一个过程 A 调用了另外两个过程 B 和 C。还假设 B 调用了两个过程 D 和 E,C 调用了两个过程 F 和 G。我们可以通过使用调用树来表示这个调用顺序,如图 5-1 所示。

图 5-1:调用树示意图

整个调用图是树形结构,底部那些不调用其他过程的过程——在这个例子中是 D、E、F 和 G——被称为叶过程

叶过程与非叶过程在 ARM 汇编语言中有所不同,因为它们可以将返回地址保留在 LR 寄存器中,而不是保存到内存(堆栈)。由于叶过程不会通过 bl 指令进行其他调用,因此在进入过程时,该过程不会修改 LR 中的值。(这假设过程不会显式地修改 LR,但通常来说,修改 LR 没有什么好的理由。)因此,叶过程可能比非叶过程稍微高效一些,因为它们不需要保留 LR 寄存器中的值。叶过程还可以充分利用易失性寄存器集,而不用担心它们的值在调用其他过程时被扰乱。

非叶过程必须保留 LR 寄存器中的值,因为它们所调用的过程(通过 bl 指令)会覆盖 LR 中的值。一个过程可以在几个地方保留 LR:在另一个寄存器中、在堆栈上,或者在全局内存位置,就像我们在第三章介绍堆栈之前的例子那样。

我已经指出,在几乎所有情况下,使用全局变量来保存 LR 是一个不好的选择。这个方案只能处理一层调用,在使用递归(参见第 5.8 节,“递归”,第 277 页)或编写多线程应用程序时完全失效。它的速度较慢,代码更多,而且比其他方案使用起来不方便。

你可以使用另一个寄存器临时保存返回地址,同时调用另一个过程。当然,这个寄存器必须是非易失性的(或者至少,被调用的过程不能修改这个寄存器的值),这样它就能在你调用的过程返回时仍然保存返回地址。像这样使用寄存器来保存 LR 是非常快速的。不幸的是,确保其他过程不会修改保存的值通常意味着你必须在第二个过程中保留这个值。由于你仍然需要将该值写入内存(并且再次读取它),那么你不如直接将 LR 保存在内存中。

保存返回地址到 LR 最常见的位置是堆栈。通常,过程中的第一条指令会将 LR 寄存器的内容移动到堆栈中。这通常有两种方式。第一种是直接将 LR 寄存器压入堆栈:

str lr, [sp, #-16]!

第二步是将堆栈向下调整,并将 LR 存入刚刚创建的存储区域:

sub sp, sp, #`someAmount`   // Make room for LR on stack.
str lr, [sp, #`someOffset`] // Store LR into space allocated on stack.

这里,someAmount 是 16 的倍数(或其他保持堆栈 16 字节对齐的值),而 someOffset 是通过 sub 指令在堆栈上刚分配的空间的索引。

注意,前面的示例使用了预索引寻址模式来将 SP 向下调整,并将 LR 存入空出的空间(由于堆栈对齐问题,这实际上保留了 16 个字节,虽然只使用了其中的 8 个字节)。后面的示例使用了间接加偏移寻址模式,简单地将返回地址存入由 sub 指令分配的存储区域。本书通常使用后一种形式,因为 sub 的成本通常与其他使用堆栈的代码共享。

通过使用预索引寻址模式浪费 8 个字节不会成为问题。正如你将很快看到的,通常你会希望保留 FP 寄存器的值和返回地址,因此你通常会使用像以下这些不会浪费内存的 stp 指令:

stp fp, lr, [sp, #-16]!

sub sp, sp, #`someAmount`
stp fp, lr, [sp, #`someOffset`]

以下小节将介绍在过程调用中堆栈的使用,包括激活记录、如何访问激活记录中的数据(本地和自动变量以及参数)、ARM ABI 如何影响激活记录和参数传递,以及如何构建和销毁激活记录。

5.4.1 激活记录

当你调用一个过程时,程序会将一些信息与该过程调用关联起来,包括返回地址、参数和自动本地变量(我将在后续章节中讨论)。为此,程序使用一种名为激活记录的数据结构,也称为堆栈帧。程序在调用(激活)过程时创建激活记录,记录中的数据以与结构体相同的方式进行组织。

本节讨论了一个假设编译器创建的传统激活记录,忽略了 ARM ABI 的参数传递约定。本章后续的章节将介绍 ARM ABI 约定。

激活记录的构建始于调用过程的代码。调用者在堆栈上为参数数据(如果有的话)腾出空间,并将数据复制到堆栈上。然后,bl 指令将返回地址传入过程。此时,激活记录的构建继续在过程内部进行。过程通常将 LR 中的值与其他寄存器和其他重要的状态信息一起推送到堆栈上,然后为本地变量在激活记录中腾出空间。过程还可能更新 FP 寄存器(X29),使其指向激活记录的基地址。

为了了解传统的激活记录是什么样的,可以考虑以下 C++过程声明:

void ARDemo(unsigned i, int j, unsigned k) 
{
     int a; 
     float r; 
     char c; 
     bool bb; 
 short w; 
      . 
      . 
      . 
}

每当程序调用该 ARDemo 过程时,它会首先将参数数据推入栈中。在原始的 C/C++ 调用约定中(忽略 ARM ABI),调用代码将所有参数按与它们在参数列表中出现的顺序相反的顺序推入栈中,从右到左。因此,调用代码首先推入 k 参数的值,然后是 j 参数的值,最后是 i 参数的数据(为了保持栈对齐,可能会为参数添加填充)。

接下来,程序调用 ARDemo。ARDemo 过程刚一进入时,栈中就包含了这三个项目,并按图 5-2 所示的顺序排列。由于程序是逆序将参数推入栈中,因此它们按正确的顺序出现在栈上,且第一个参数位于内存中的最低地址。

图 5-2:ARDemo 进入时的栈组织结构

ARDemo 中的前几个指令将当前的 LR 和 FP 值推入栈中,然后将 SP 的值复制到 FP。接下来,代码会将栈指针向下调整到内存中,为局部变量腾出空间。这会产生图 5-3 所示的栈组织结构。

图 5-3:ARDemo 的激活记录

由于局部变量在激活记录中可以是任意大小,因此它们的总存储空间可能不是 16 字节的倍数。然而,整个局部变量块必须是 16 字节的倍数,以确保栈指针(SP)保持在 ARM CPU 所要求的 16 字节对齐边界上——因此,图 5-3 中可能会出现填充。

5.4.2 激活记录中的对象

要访问激活记录中的对象,可以使用从 FP 寄存器到目标对象的偏移量。你最关心的两个项目是参数和局部变量。你可以通过从 FP 寄存器的正偏移量访问参数;可以通过从 FP 寄存器的负偏移量访问局部变量,正如图 5-4 所示(该图假设 i、j 和 k 参数都是 64 位整数,并已适当填充到 8 字节每个)。

ARM 特别为 X29/FP 寄存器保留了作为激活记录基址指针的用途。这就是为什么你应该避免将 FP 寄存器用于一般计算。如果你随意更改 FP 寄存器的值,可能会导致无法访问当前过程的参数和局部变量。

图 5-4:ARDemo 激活记录中对象的偏移量

局部变量按照与其本地大小相等的偏移量进行对齐:字符(chars)对齐在 1 字节地址上;短整数(shorts/hwords)对齐在 2 字节地址上;长整数、整数、无符号整数和字(longs、ints、unsigned 和 words)对齐在 4 字节地址上,依此类推。在 ARDemo 示例中,所有局部变量恰好都分配在适当的地址上(假设编译器按声明顺序分配存储)。

5.4.3 ARM ABI 参数传递约定

ARM ABI 对激活记录模型进行了若干修改:

  • 调用者通过寄存器(X0 至 X7)传递前八个(非浮点)参数,而不是通过栈传递。

  • 参数始终是 8 字节值,无论是在寄存器中还是在栈上(如果形式参数小于 8 字节,则未使用的高位(HO bits)是未定义的)。

  • 大于 16 字节的结构体和联合体通过值传递在栈上,位于其他参数之上,但在正常的参数位置(寄存器或栈上)传递指向该值的指针。8 字节(或更少)的结构体和联合体通过 64 位寄存器传递;9 到 16 字节的结构体和联合体通过两个连续的寄存器传递。

你只需在调用符合 ARM ABI 的代码时遵循这些约定。对于你编写和调用的汇编语言过程,你可以使用任何约定。

苹果的调用约定对于 macOS(iOS、iPadOS 等)与标准 ARM ABI 稍有不同。如果你进行以下操作,可能会影响你的汇编代码:

  • 向过程传递超过八个参数

  • 向变参过程传递参数

当通过栈传递参数时——即当你向函数传递超过八个参数时——苹果将它们打包在栈上,这意味着它不会简单地为每个参数分配 8 字节的栈空间。它确实确保每个值在内存中按照其自然大小进行对齐(字符 = 1 字节,半字 = 2 字节,字 = 4 字节,依此类推)。

变参过程 是指具有可变数量参数的过程,例如 C 的 printf() 函数。苹果将所有变参参数放在栈上,并为每个参数分配 8 字节,不管其类型如何。这就是 aoaa.inc 中 vparm2、vparm3 等宏的目的:macOS 下的 printf() 调用必须将参数传递到栈上,而在 Linux 下的相同调用则将前八个参数传递到寄存器中。

vparm2、vparm3 等宏根据操作系统自动生成适当的代码(无论是将参数放入栈中还是通过寄存器传递)。

5.4.4 标准入口序列

一个过程的调用者负责为参数在栈上分配存储并将参数数据移动到适当的位置。在最简单的情况下,这只是通过使用 strstp 指令将数据移动到栈上。构建其余的激活记录是过程的责任。你可以通过使用以下汇编语言的标准入口序列代码来实现:

stp fp, lr, [sp, #-16]!   // Save LR and FP values.
mov fp, sp                // Get activation record ptr in FP.
sub sp, sp, #`NumVars`      // Allocate local storage.

mov fp, sp 指令将当前保存在 SP 中的地址复制到 FP 寄存器。由于 SP 当前指向栈上压入的旧 FP 值,执行此指令后,FP 将指向原始的 FP 值,如图 5-4 所示。当在标准入口序列中使用 stp 指令时,确保将 FP 寄存器作为第一个参数,以便它存储在 [SP] 位置,而 LR 存储在 [SP, #8] 位置。这确保了在执行 mov 指令后,FP 会指向旧的 FP 值。

在第三条指令中,NumVars 代表过程所需的本地变量的字节数,这是一个常量,应该是 16 的倍数,以确保 SP 寄存器在 16 字节边界上对齐。如果过程中的本地变量字节数不是 16 的倍数,则在从 SP 中减去该常量之前,将该值向上舍入到下一个更高的 16 的倍数。这样做会稍微增加过程用于本地变量的存储量,但不会影响过程的操作。如果过程没有本地变量或调用其他函数,

sub sp, sp, #`NumVars`

指令不是必须的。

理论上,你可以使用任何寄存器来访问栈帧中的数据。然而,操作系统,尤其是调试器应用程序,通常依赖于激活记录的构建,其中 FP 指向激活记录中的旧 FP 值。

如果一个符合 ARM ABI 的程序调用你的过程,在执行 bl 指令之前,栈将被对齐到 16 字节边界。将 LR 和 FP 压入栈中(在将 SP 复制到 FP 之前)会再增加 16 字节的栈空间,以确保 SP 保持 16 字节对齐。因此,假设在调用之前栈已经是 16 字节对齐的,并且从 SP 中减去的数字是 16 的倍数,那么在为本地变量分配存储之后,栈仍然会保持 16 字节对齐。

前一节中的 ARDemo 激活记录只有 12 字节的本地存储。因此,从 SP 中减去 12 字节用于本地变量将无法保持栈的 16 字节对齐。ARDemo 程序中的入口序列必须减去 16(其中包括 4 字节的填充),以保持栈的正确对齐(如图 5-4 所示)。

一个可能的替代入口代码序列,与前面的示例等效,其形式如下:

sub sp, sp, #`numVars` + 16   // Space for locals and SP/LR
stp fp, lr, [sp, #`numVars`]
add fp, sp, #`numVars`

ARM ABI 调用约定建议将 LR 和 FP 值保存局部变量的下面。然而,在分配局部变量的同时,分配额外的参数空间以支持从当前过程的额外过程调用通常是方便的。如果将 LR 和 FP 值保存在激活记录的底部,你将需要额外的指令来为这些参数腾出空间,而在过程返回时清理激活记录将更加复杂。

因为你将频繁使用标准的入口序列,aoaa.inc 包含文件提供了一个宏来为你生成该序列:

enter `numVars`

唯一的常量参数是要分配的栈空间大小(用于局部变量和其他内存对象),除了为保存 LR 和 FP 寄存器所预留的 16 字节之外。这个宏会为入口序列生成以下指令序列:

stp fp, lr, [sp, #-16]!
mov fp, sp
sub sp, sp, #(`numVars` + 15) & 0xFFFFFFFFFFFFFFF0

最终的表达式涉及 numVars,确保在栈上分配的空间是 16 字节的倍数,以保持栈的 16 字节对齐。

5.4.5 标准退出序列

汇编语言程序的标准退出序列如下:

mov sp, fp             // Deallocates storage for all the local vars
ldp fp, lr, [sp], #16  // Pop FP and return address.
ret                    // Return to caller.

aoaa.inc 包含文件中,leave 宏展开为原始的标准退出序列。

5.5 局部变量

大多数高级语言中的过程和函数允许你声明局部变量(也称为自动变量)。前面章节提到过,过程将局部变量保存在激活记录中,但它们并没有真正定义如何创建和使用它们。本节(及其后续小节)将定义局部变量,并描述如何为其分配存储空间并使用它们。

局部变量在高级语言中有两个特殊的属性:作用域和生命周期 标识符的作用域决定了该标识符在编译期间在源文件中可见(可访问)的范围。在大多数高级语言中,过程的局部变量的作用域是该过程的主体;标识符在该过程外部不可访问。遗憾的是,Gas 不支持过程中的局部作用域变量,因为 Gas 没有语法来确定过程的边界。

作用域是符号的编译时属性,而生命周期是运行时属性。变量的生命周期是一个时间范围,从存储首次绑定到变量的那一刻,直到存储不再可用的时刻。静态对象(那些你在 .data、.rodata、.bss 和 .text 部分声明的对象)具有与应用程序总运行时间等同的生命周期。程序在加载到内存时为这些变量分配存储空间,并且这些变量在程序终止之前保持该存储空间。

局部变量,更准确地说是自动变量,在进入过程时会分配存储空间。该存储空间在过程返回调用者时会被释放,供其他用途。自动这一名称意味着程序在调用和返回过程中自动分配和释放变量的存储。

在 Linux 下,一个过程可以像主程序访问全局变量一样,使用 PC 相对寻址模式访问任何全局的.data、.bss 或.rodata 对象(遗憾的是,macOS 的 PIE 格式不允许轻松访问非.text 节的对象)。访问全局对象是方便且简单的。然而,访问全局对象会让程序更难阅读、理解和维护,因此应该避免在过程内使用全局变量。

尽管在过程内访问全局变量有时可能是解决特定问题的最佳方案,但你在这个阶段可能不会编写这样的代码,因此在这么做之前需要仔细考虑你的选择。(一个合法使用全局变量的例子可能是在多线程应用程序中共享线程间的数据,这稍微超出了本章的范围。)

然而,这种反对访问全局变量的论点并不适用于其他全局符号。在程序中访问全局常量、类型、过程和其他对象是完全合理的。

5.5.1 自动变量的低级实现

你的程序通过使用从激活记录基址(FP)负偏移量来访问过程中的局部变量。请参考列表 5-5 中的 Gas 过程,主要用于演示局部变量的使用。

// Listing5-5.S
//
// Accessing local variables

#include "aoaa.inc"

               .text

// local_vars
//
// Word a is at offset -4 from FP.
// Word bb is at offset -8 from FP.
//
// On entry, W0 and W1 contain values to store
// into the local variables a & bb (respectively).

            proc    local_vars
            enter   8

            str     w0, [fp, #-4]   // a = W0
            str     w1, [fp, #-8]   // bb = W1

    // Additional code here that uses a & bb

            leave
            endp    local_vars

这个程序无法运行,因此我不会提供构建命令。enter 宏实际上会分配 16 字节的存储,而不是参数指定的 8 字节(用于局部变量 a 和 bb),以保持栈的 16 字节对齐。

local_vars 的激活记录出现在图 5-5 中。

图 5-5:local_vars 过程的激活记录

当然,通过 FP 寄存器的数值偏移来引用局部变量是非常糟糕的。这段代码不仅难以阅读([FP, #-4]是 a 变量还是 bb 变量?),而且也难以维护。例如,如果你决定不再需要 a 变量,你就得找到每一个[FP, #-8](访问 bb 变量)的地方,并将其改成[FP, #-4]。

一个稍微更好的解决方案是为你的局部变量名创建等式。请参考列表 5-5 的修改版,如列表 5-6 所示。

// Listing5-6.S
//
// Accessing local variables #2

#include "aoaa.inc"

            .code

// local_vars
//
// Demonstrates local variable access
//
// Word a is at offset -4 from FP.
// Word bb is at offset -8 from FP.
//
// On entry, W0 and W1 contain values to store
// into the local variables a & bb (respectively).

#define a [fp, #-4]
#define bb [fp, #-8]

            proc    local_vars
            enter   8

 str     w0, a
            str     w1, bb

    `Additional code here that uses a & bb.`

            leave
            endp    local_vars

在列表 5-6 中,CPP 用适当的间接加偏移寻址模式替换了 a 和 bb,以便在栈上访问这些局部变量。这比列表 5-5 中的程序更容易阅读和维护。然而,这种方法仍然需要一些手动工作来设置 #define 语句中的局部变量偏移量,并且修改代码(在添加或删除局部变量时)可能会导致维护问题。我将在下一节提供一个更好的解决方案。

自动存储分配的一个大优点是,它能够有效地在多个过程之间共享一块固定的内存池。例如,假设你依次调用三个过程,像这样:

bl ProcA
bl ProcB
bl ProcC

在这个例子中,ProcA 在栈上分配它的局部变量。返回时,ProcA 会释放这块栈存储空间。进入 ProcB 时,程序通过使用 ProcA 刚刚释放的内存位置来分配 ProcB 的局部变量存储空间。同样,当 ProcB 返回并且程序调用 ProcC 时,ProcC 使用与 ProcB 最近释放的相同栈空间来存储它的局部变量。这种内存重用有效地利用了系统资源,可能是使用自动变量的最大优点。

现在你已经了解了汇编语言如何分配和释放局部变量的存储空间,就很容易理解为什么自动变量在两次调用同一个过程之间不会保持它们的值。一旦过程返回到调用者,自动变量的存储就会丢失,因此,值也会丢失。因此,你必须始终假设进入过程时局部变量对象是未初始化的。如果你需要在两次调用过程之间保持变量的值,应该使用静态变量声明类型中的一种。

5.5.2 locals 宏

使用等式来维护局部变量引用是一项繁重的工作。诚然,这比在所有局部变量引用中使用魔法数字要好,但即使使用等式,在过程中插入和删除局部变量仍然需要时间和精力。真正理想的情况是有一个声明部分,可以像在高级语言中那样声明局部变量,并让汇编器负责维护所有进入激活记录的偏移量。aoaa.inc 头文件提供了一组宏,你可以用它们来自动化创建局部变量的过程。本节将介绍这些宏。

激活记录是一个记录(结构体)。理论上,你可以使用第四章中的 struct 宏来定义激活记录。然而,修改 struct/ends 宏来为局部变量创建更好的东西其实并不难。为了实现这一点,aoaa.inc 包含了两个额外的宏,用于声明局部变量:locals 和 endl。你可以几乎像使用 struct/ends 宏那样使用这些宏。

locals `procName`
  `declarations (same as for struct)`
endl   `procName`

其中 procName 是标识符(通常是与局部变量关联的过程的名称)。

像 ends 宏一样,endl 生成一个名为 procName.size 的符号,这是一个等式,设置为局部变量空间的大小。你可以将这个值提供给 enter 宏,以指定为局部变量保留的空间量:

 proc   myProc

    locals myProc
    dword  mp.ptrVar
    word   mp.counter
    byte   mp.inputChar
    salign 4
    word   mp.endIndex
    endl   myProc

    enter  myProc.size

`Insert procedure's body here.`

    leave
    endp   myProc

locals/endl 声明创建了一组等式,这些等式的值对应于激活记录中符号的偏移量。例如,前面的示例中的符号具有以下值:

mp.ptrVar    –8

mp.counter    –12

mp.inputChar    –13

mp.endIndex    –20

你可以使用这些偏移量与[FP, #offset]寻址模式来引用激活记录中的局部变量。例如:

ldr w0, [fp, #mp.counter]
ldr x1, [fp, #mp.ptrVar]
str w0, [x1]

这比在 .data 区访问全局变量要容易得多!

当为局部变量和 endl 宏之间的变量分配偏移量时,声明宏首先通过变量声明的大小减少偏移量计数器,然后将减少后的偏移值分配给符号。指定 salign 指令将会将偏移量调整到指定的边界(2^n,其中 n 是 salign 操作数的值)。下一个声明将不使用此偏移量,而是首先通过声明的大小递减运行中的偏移量计数器,并将该偏移量分配给变量。在之前的示例中,salign 指令将运行偏移量设置为 –16(因为当时分配了 13 字节的变量)。接下来的变量偏移量是 –20,因为 mp.endIndex 消耗了 4 字节。

如我之前所提到的,Gas 不支持词法作用域局部变量名的概念,这些变量名对于一个过程是私有的。因此,你在 locals/endl 块内声明的所有符号在整个源文件中都是可见的。这可能导致 命名空间污染,你可能会在一个过程内创建名字,而不能在另一个过程中重复使用这些名字。

在本节的示例中,我使用一种惯例,并且在本书中始终使用这种惯例,以避免命名空间污染:我使用形式为 proc.local 的局部变量名,其中 proc 是过程的名称(或过程名称的缩写),local 是我要使用的具体局部变量名称。例如,mp.ptrVar 是 myProc(mp)过程中的 ptrVar 局部变量。

5.6 参数

尽管许多过程是完全自包含的,大多数过程仍需要输入数据并将数据返回给调用者(参数)。

讨论参数时,第一个需要考虑的方面是我们如何将参数传递给过程。如果你熟悉 Pascal 或 C/C++,你可能见过两种传递参数的方式:按值传递和按引用传递。你在高级语言中能做的任何事情,都能在汇编语言中实现(显然,高级语言代码最终会被编译成机器代码),但你必须提供合适的指令序列来访问这些参数。

处理参数时,另一个需要关注的问题是你在哪里传递它们。传递参数有许多方式:通过寄存器、栈、代码流、全局变量或它们的组合。以下小节将介绍几种可能性。

5.6.1 按值传递

按值传递的参数就是这样——调用者将一个值传递给过程。按值传递的参数仅为输入参数。你可以将它们传递给过程,但过程不能通过它们返回值。考虑以下 C/C++ 函数调用:

CallProc(I);

如果你按值传递 I,那么无论 CallProc() 内部如何操作,I 的值都不会改变。

因为你必须将数据的副本传递给过程,所以这种方法仅适用于传递像字节、字、双字和四字等小对象。按值传递大型数组和记录效率低,因为你必须创建并传递对象的副本给过程。

5.6.2 按引用传递

为了按引用传递参数,你必须传递一个变量的地址,而不是它的值。换句话说,你必须传递指向数据的指针。过程必须解除引用该指针才能访问数据。当你需要修改实际参数,或者在过程之间传递大型数据结构时,按引用传递参数是很有用的。因为在 ARM 上指针是 64 位宽的,所以你按引用传递的参数将由一个双字值组成,通常保存在一个通用寄存器中。

你可以使用 lea 宏获取你在 .data、.bss、.rodata 或 .text 段中声明的任何静态变量的地址。清单 5-7 演示了如何获取静态变量(staticVar)的地址,并将该地址传递给一个过程(someFunc),地址通过 X0 寄存器传递。

// Listing5-7.S
//
// Demonstrate obtaining the address
// of a variable by using the lea instruction.

#include "aoaa.inc"

            .data
staticVar:  .word   .-.

            .code
            .extern someFunc

            proc    get_address
            enter   0
            lea     x0, staticVar
            bl      someFunc
            leave
            endp    get_address

计算非静态变量的地址则需要更多的工作。不幸的是,adr 和 adrp 指令只计算与程序计数器(PC)相关的内存访问地址。如果你的变量是通过其他 ARM 寻址模式引用的,你将不得不手动计算有效地址。

表格 5-1 描述了有效地址计算的过程。在表格中,[Xn, #const](缩放形式)寻址模式描述的是机器编码,而不是汇编语法。在源代码中,缩放和非缩放形式共享相同的语法:[Xn, #const]。汇编程序会根据常数的值选择正确的机器编码。

表格 5-1:有效地址计算

寻址模式 有效地址 描述
[Xn] Xn 对于寄存器间接寻址模式,有效地址就是寄存器中保存的值。
[Xn, #const] Xn + const 对于间接加偏移寻址模式,有效地址是 Xn 寄存器和常量的和。假设常量范围为 -256 到 +255,且偏移为 0。
[Xn, #const] Xn + const (scaled) 对于带标度的间接加偏移模式(其中标度因子由加载或存储的数据大小决定),常量在与 Xn 寄存器相加之前,必须先乘以内存操作数的大小。对于 strb/ldrb,乘数为 1;对于 strh/ldrh,乘数为 2;对于 str/ldr(字寄存器),乘数为 4;对于 str/ldr(双字寄存器),乘数为 8。对于 strb/ldrb,常量必须在 0 到 4,096 之间。对于 strh/ldrh,常量必须在 0 到 8,191 之间,并且必须是偶数。对于使用字寄存器操作数的 ldr/str,常量必须在 0 到 16,383 之间,并且必须是 4 的倍数。对于使用双字寄存器操作数的 ldr/str,常量必须在 0 到 32,767 之间,并且必须是 8 的倍数。
[Xn, #const]! Xn + const 对于预索引寻址模式,有效地址是 Xn 寄存器和常量的和。
[Xn], #const Xn 对于后索引寻址模式,有效地址就是 Xn 寄存器中的值。
[Xn, Xm] Xn + Xm 对于标度索引寻址模式,标度因子为 1 时,有效地址是两个寄存器的和(如果指定,Xm 为符号扩展或零扩展)。
[Xn, Xm, extend #s] Xn + (Xm << s) 对于带有移位扩展的标度索引寻址模式,有效地址是 Xn 与 Xm 寄存器的值左移 s 位后的和(如果指定,Xm 为零扩展或符号扩展)。

假设一个过程有一个局部变量,并且你想通过引用将该变量传递给第二个过程。由于你使用 [FP, #offset] 寻址模式访问局部变量,有效地址为 FP + 偏移量。你必须使用以下指令来计算该变量的地址(将地址存放在 X0 中):

add x0, fp, #`offset`

示例 5-8 演示了将局部变量作为引用参数传递给过程。

// Listing5-8.S
//
// Demonstrate passing a local variable
// by reference to another procedure.

#include "aoaa.inc"

            .data
staticVar:  .word   .-.

            .code
            .extern aSecondFunction

            proc    demoPassLclByRef

            locals  ga
            word    ga.aLocalVariable
            endl    ga

            enter   ga.size
            add     x0, fp, #ga.aLocalVariable // Pass parameter in X0.
            bl      aSecondFunction

            leave
            endp    demoPassLclByRef

通过引用传递通常比通过值传递效率低。你必须在每次访问时解引用所有按引用传递的参数;这比简单地使用值更慢,因为通常至少需要两条指令:一条将地址取到寄存器中,另一条通过该寄存器间接取值。

然而,在传递大型数据结构时,通过引用传递更为高效,因为你不需要在调用过程之前复制这个大型数据结构。当然,你可能需要通过指针来访问该大型数据结构的元素(比如数组),所以通过引用传递大型数组时,效率损失几乎可以忽略不计。

5.6.3 使用低级参数实现

参数传递机制是调用者与被调用者(过程)之间的契约。双方必须达成一致,确定参数数据的出现位置以及它的形式(例如,值或地址)。

如果你的汇编语言过程仅由你编写的其他汇编语言代码调用,那么你控制着双方的契约协商,并可以决定参数的传递位置和方式。然而,如果外部代码调用你的过程,或者你的过程调用外部代码,那么你的过程必须遵守外部代码使用的任何调用约定。

在讨论特定的调用约定之前,本节首先考虑了你自己编写的调用代码的情况(因此,你对其调用约定拥有完全的控制权)。接下来的各节描述了你可以在纯汇编语言代码中传递参数的各种方式(没有与 ARM 或 macOS ABI 相关的开销)。

5.6.3.1 通过寄存器传递参数

在简要介绍了如何传递参数之后,接下来要讨论的是在哪里传递参数。这取决于参数的大小和数量。如果你传递的是少量的参数,寄存器是一个非常好的传递位置。如果你传递的是单个参数,将该数据传递在 X0 中,如表 5-2 所述。

表 5-2:参数大小与位置

参数大小 位置
字节 将字节参数传递在 W0 的低字节部分。
半字 将半字参数传递在 W0 的低半字部分。
将字传递在 W0 中。
双字 将双字传递在 X0 中。
> 8 字节 我建议在 X0 中传递指向数据结构的指针,或者如果是 16 字节或更少,在 X0/X1 中传递值。

当在 X0 中传递少于 32 位的数据时,macOS ABI 要求该值在整个 X0 寄存器中进行零扩展或符号扩展。ARM ABI 则没有这个要求。当然,在向你编写的汇编语言过程传递数据时,由你来定义如何处理高位。最安全的做法是将值零扩展或符号扩展到高位(取决于值是无符号还是有符号),这种做法在任何地方都具有可移植性。

如果你需要传递超过 8 个字节的参数,你也可以通过多个寄存器传递这些数据(例如,在 macOS 和 Linux 中,C/C++编译器会通过两个寄存器传递一个 16 字节的结构)。无论你是将参数作为指针传递,还是通过多个寄存器传递,都取决于你。

对于通过寄存器传递参数,ARM ABI 保留了 X0 到 X7 寄存器。当然,在纯汇编语言代码中(它不会调用或被 ARM ABI 兼容代码调用),你可以使用任何你选择的寄存器。然而,X0 到 X7 应该是你的首选,除非你能提供使用其他寄存器的充分理由。

八个参数可能涵盖了 95%的程序。 如果你传递给纯汇编程序的参数超过八个,没人会阻止你使用更多的寄存器(例如,X8 到 X15)。同样,如果你真的想这么做,也没人会阻止你通过多个寄存器传递大对象。

5.6.3.2 在代码流中传递参数

你还可以在bl指令后立即在代码流中传递参数。考虑以下打印例程,它将一个字面字符串常量打印到标准输出设备:

bl      print
.asciz "This parameter is in the code stream..."

通常,一个子程序会将控制权返回到bl指令后的第一条指令。如果在这里发生这种情况,ARM 将尝试将"This..."的 ASCII 码解释为一条指令。这将产生不希望出现的结果。幸运的是,你可以在从子程序返回之前跳过这个字符串。

然而,ARM CPU 的设计存在一个大问题:所有指令必须在内存中按照字对齐。因此,出现在代码流中的参数数据必须是 4 字节的倍数(在这个例子中,我选择了包含 39 个字符的字符串,这样零终止字节使整个序列达到了 40 字节)。

那么,如何访问这些参数呢?很简单:LR 中的返回地址指向它们。请参考清单 5-9 中print的实现。

// Listing5-9.S
//
// Demonstrate passing parameters in the code stream

#include "aoaa.inc"

            .text
            .pool
ttlStr:     .asciz      "Listing 5-9"
            .align      2

// getTitle
//
// Return program title to C++ program:

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// print
//
// Here's the print procedure.
// It expects a zero-terminated string
// to follow the call to print:

rtnAdrs     =       8           // Offset to rtn adrs from FP.

            proc    print

 ❶ locals  print
            qword   print.x0X1Save   // Register save area.
            qword   print.x2X3Save
            qword   print.x4X5Save
            qword   print.x6X7Save
            qword   print.x8X9Save
            qword   print.x10X11Save
            qword   print.x12X13Save
            qword   print.x14X15Save
            endl    print

            enter   print.size

// Assembly language convention--save all the registers
// whose values we change. Spares caller from having to
// preserve volatile registers.
// Note: this code calls ABI function write, so you must
// preserve all the volatile registers.

            stp     x0, x1, [fp, #print.x0X1Save]
            stp     x2, x3, [fp, #print.x2X3Save]
            stp     x4, x5, [fp, #print.x4X5Save]
            stp     x6, x7, [fp, #print.x6X7Save]
            stp     x8, x9, [fp, #print.x8X9Save]
            stp     x10, x11, [fp, #print.x10X11Save]
            stp     x12, x13, [fp, #print.x12X13Save]
            stp     x14, x15, [fp, #print.x14X15Save]

// Compute the length of the string immediately following
// the call to this procedure:

          ❷ mov     x1, lr              // Get pointer to string.
search4_0:  ldrb    w2, [x1], #1        // Get next char.
            cmp     w2,  #0             // At end of string?
            bne     search4_0           // If not, keep searching.
            sub     x2, x1, lr          // Compute string length.

// LR now points just beyond the 0 byte. We need to
// make sure this address is 4-byte aligned:

          ❸ add     x1, x1, #3
            and     x1, x1, #-4         // 0xfff...fff0

// X1 points just beyond the 0 byte and padding.
// Save it as the new return address:

          ❹ str     x1, [fp, #rtnAdrs]

// Call write to print the string to the console.
//
// write(fd, bufAdrs, len);
//
// fd in X0 (this will be 1 for stdout)
// bufAdrs in X1
// len in X2

          ❺ mov     x0, #1         // stdout = 1
            mov     x1, lr         // Pointer to string
            bl      write

// Restore the registers we used:

          ❻ ldp     x0, x1, [fp, #print.x0X1Save]
            ldp     x2, x3, [fp, #print.x2X3Save]
            ldp     x4, x5, [fp, #print.x4X5Save]
            ldp     x6, x7, [fp, #print.x6X7Save]
            ldp     x8, x9, [fp, #print.x8X9Save]
            ldp     x10, x11, [fp, #print.x10X11Save]
            ldp     x12, x13, [fp, #print.x12X13Save]
            ldp     x14, x15, [fp, #print.x14X15Save]
            leave                  // Return to caller.
            endp    print

// Here is the asmMain function:

            proc    asmMain, public
            enter   64

// Demonstrate passing parameters in code stream
// by calling the print procedure:

            bl      print
          ❼ .asciz  "Hello, world!!\n"

            leave   // Returns to caller
            endp    asmMain

打印过程 ❶ 保存它所修改的所有寄存器(即使是易失性寄存器,因为调用write()可能会覆盖它们)。这是汇编语言的常规约定,但对于打印来说尤其重要,因为你希望能够打印(调试)信息,而不必在每次调用之间保存寄存器值。

当进入打印过程时,LR 指向要打印的字符串 ❷。这段代码扫描该字符串以找到零终止字节;这个扫描会产生字符串的长度和(大致的)返回地址。

因为代码必须在 4 字节对齐的边界上执行,返回地址不一定是零终止字节后的一个字节。相反,代码可能需要将字符串指针的末尾填充 1 到 3 字节,以便在 .text 区段中跳到下一个字边界 ❸。将 3 加上后,再与 0xFFFFFFFFFFFFFFFC(-4)进行按位与运算,填充返回地址到适当的边界。然后,代码会将返回地址覆盖原有的堆栈地址 ❹。

一旦你得到字符串的长度,就可以调用 C 标准库的写入函数来打印它 ❺(如果第一个参数是 0,则将字符串打印到标准输出设备)。在退出时,代码会恢复之前保存的寄存器 ❻。

在这个例程中,我添加了两个感叹号 ❼,使得字符串的长度(包括零终止字节)成为 4 的倍数。这确保了接下来的指令能够在 4 字节对齐的边界上执行。

为了避免总线错误,打印调用后跟随的数据长度必须是 4 字节的倍数,以确保下一条指令能够正确地在 4 字节对齐的边界上执行。字符串本身的长度不必是 4 字节的倍数;在零终止字节后加上任意的填充字节是可以的。你可以不必计算字符串中的字符数,而是使用 Gas 的 .p2align 指令。这个指令会将位置计数器对齐到一个是 2^n 字节倍数的边界,其中 n.p2align 操作数字段中的第一个值。例如

.p2align 2

将位置计数器填充到下一个字边界。

使用 .p2align 2 指令,你可以如下所示调用打印过程,传递任意长度的字符串:

bl        print
.asciz    "Hello, world!\n"
.p2align  2

记得在代码中加入 .p2align 2 指令可能会很困难,更不用说需要输入它还很麻烦,而且会让你的代码变得杂乱。为了解决这个问题,aoaa.inc 包含了一个 wastr(字对齐字符串)宏,它会自动为你添加填充:

bl       print
wastr    "Hello, world!\n"

除了展示如何在代码流中传递参数外,打印例程还展示了另一个概念:可变长度参数(字符串的长度可以是任意长)。跟在 bl 后面的字符串可以是任何实际长度。以零终止的字节标记参数列表的结束。你可以通过两种简单的方式处理可变长度参数:要么使用一个特殊的终止值(如 0),要么传递一个特殊的长度值,告诉子例程你传递了多少参数。两种方法各有优缺点。

使用特殊值来终止参数列表要求选择一个永远不会出现在列表中的值。例如,print 使用 0 作为终止值,因此它不能打印 NUL 字符(其 ASCII 码为 0)。有时,这并不是一个限制。指定长度参数是另一种可以用来传递可变长度参数列表的机制。虽然这不需要任何特殊代码,也不会限制可以传递给子例程的值范围,但设置长度参数并维护结果代码可能会成为一个真正的噩梦;尤其是在参数列表经常变化的情况下,这一点尤为明显。

尽管在代码流中传递参数带来了便利,但这种方法也有缺点。首先,如果没有提供程序所需的精确数量的参数,子例程将会混淆。考虑 print 的例子。它打印一串字符直到遇到一个零终止字节,然后将控制权返回到该字节后的第一条指令。如果没有提供零终止字节,print 例程会很高兴地将随后的操作码字节作为 ASCII 字符打印,直到找到一个 0 字节。由于 0 字节经常出现在指令的中间,print 例程可能会将控制权返回到另一条指令的中间,这可能会导致机器崩溃。

在 ARM 上,必须确保传递的参数在代码流中是 4 字节的倍数。紧随其后的指令必须位于字边界上。尽管存在一些问题,然而,代码流仍然是传递那些值不变的参数的高效方式。

5.6.3.3 在栈上传递参数

大多数高级语言(HLL)使用栈来传递大量参数,因为这种方法相当高效。尽管在栈上传递参数的效率稍逊于在寄存器中传递,但寄存器集是有限的(尤其是如果你只限于 ARM ABI 为此目的预留的 8 个寄存器)。另一方面,栈允许你轻松地传递大量参数数据。这就是为什么大多数程序在栈上传递参数的原因(至少在传递超过 8 个参数时)。

要手动在栈上传递参数,在调用子例程之前立即将其压入栈中(只需记住保持栈的 16 字节对齐)。子例程随后从栈内存中读取这些数据并进行适当的操作。考虑以下 HLL 函数调用:

CallProc(i,j,k);

因为保持 SP 在 16 字节边界上对齐至关重要,所以你不能仅通过 str 指令一次推送一个参数,也不能推送小于 32 位的值。假设 i、j 和 k 是 32 位整数,你需要将它们以某种方式打包成一个 128 位的单元(包括多余的 32 位未使用数据),然后将 16 字节推送到栈上。这是非常不方便的,因此 ARM 代码几乎从不将单个(甚至成对的)寄存器值推送到栈上。

在 ARM 汇编语言中常见的解决方案是,首先将栈下移所需的字节数(加上任何填充字节,以确保栈对齐正确),然后将参数存储到这样分配的栈空间中。例如,要调用 CallProc,你可以使用如下代码:

sub sp, sp, #16   // Allocate space for parameters.
str w0, [sp]      // Assume i is in W0,
str w1, [sp, #4]  // j is in W1, and
str w2, [sp, #8]  // k is in W2.
bl  CallProc
add sp, sp, #16   // Caller must clean up stack.

sub 指令在栈上分配了 16 字节;你只需要 12 字节来存放三个 32 位参数,但必须分配 16 字节以保持栈对齐。

这三条 str 指令将参数数据(假定该代码将其存储在 W0、W1 和 W2 中)存储到从 SP + 0 到 SP + 11 的 12 字节中。CallProc 将简单地忽略栈上分配的额外 4 字节。

在这个例子中,三个 32 位整数被打包到内存中,每个整数在栈上占用 4 字节。因此,i 参数在进入 CallProc 时位于 SP + 0,j 参数位于 SP + 4,k 参数位于 SP + 8(见 图 5-6)。

图 5-6:进入 CallProc 时的栈布局

如果你的过程包含标准的入口和退出序列,你可以通过从 FP 寄存器索引直接访问激活记录中的参数值。考虑以下声明的 CallProc 激活记录布局:

proc  CallProc
enter 0         // No local variables
  .
  .
  .
leave
endp  CallProc

此时,i 的值可以在 [FP, #16] 找到,j 的值可以在 [FP, #20] 找到,k 的值可以在 [FP, #24] 找到(见 图 5-7)。

图 5-7:标准入口序列后的 CallProc 激活记录

在 CallProc 过程内,你可以使用以下指令访问参数值:

ldr w0, [fp, #16]
ldr w1, [fp, #20]
ldr w2, [fp, #24]

当然,像这样使用魔法数字来引用参数偏移量仍然是一个不好的主意。最好使用等式,或者更好的是,创建一个类似于结构体和局部变量的声明宏来定义过程的参数。aoaa.inc 文件包含了这样一个宏:args(和 enda)。清单 5-10 演示了该宏的使用。

// Listing5-10.S
//
// Accessing a parameter on the stack

#include "aoaa.inc"

            .data
value1:     .word   20
value2:     .word   30
pVar:       .word   .-.

ttlStr:     .asciz  "Listing 5-10"
fmtStr1:    .asciz  "Value of parameter: %d\n"

            .code
            .extern printf

// getTitle
//
// Return program title to C++ program.

            proc    getTitle, public
            lea     x0, ttlStr
 ret
            endp    getTitle

// valueParm
//
// Passed a single parameter (vp.theParm) by value

            proc    valueParm

            args    vp          // Declare the
            word    vp.theParm  // parameter.
            enda    vp

            enter   64          // Alloc space for printf.

// vparms macro accepts only global variables.
// Must copy parameter to that global to print it:

            lea     x0, fmtStr1
            ldr     w1, [fp, #vp.theParm]
            lea     x2, pVar
            str     w1, [x2]
            vparm2  pVar
            bl      printf

            leave
            ret
            endp    valueParm

// Here is the asmMain function:

            proc    asmMain, public
            enter   64

            lea     x0, value1
            ldr     w1, [x0]
            str     w1, [sp]        // Store parameter on stack.
            bl      valueParm

            lea     x0, value2
            ldr     w1, [x0]
            str     w1, [sp]        // Store parameter on stack.
            bl      valueParm

            leave
            endp    asmMain

args 宏需要一个参数列表名称,可以是过程名称或其缩写,此外还可以指定一个可选的第二个参数作为起始偏移量。第二个参数默认为 16,这是一个适用于使用标准入口序列的过程的合适值(该序列将 LR 和 FP 寄存器压入栈中)。你声明的参数的偏移量是相对于过程中的 FP 的偏移量。

这是第 5-10 号列表的构建命令和示例输出:

% ./build Listing5-10
% ./Listing5-10
Calling Listing5-10:
Value of parameter: 20
Value of parameter: 30
Listing5-10 terminated

如果你的程序没有使用标准的进入顺序,你可以将一个显式的偏移量作为第二个参数。例如:

args procName, 0

如果在过程中没有将任何内容推入栈中(或分配局部变量),使用 0 是一个不错的值;这样偏移量是基于 SP,而不是基于 FP。

5.6.3.4 被调用者与调用者栈清理中的参数移除

在通过栈传递参数时,这些参数最终必须从栈中移除。ARM ABI 规定调用者负责移除它推入栈中的所有参数。本书中大多数示例程序到目前为止已经(隐式地)完成了这一操作。

每次调用过程后移除参数是缓慢且低效的。幸运的是,一个简单的优化消除了为每个函数调用分配和释放参数存储的需要。进入过程时,在分配局部变量的存储空间时,额外分配一些存储空间,用于存储过程传递给其他函数的参数。事实上,这也是本书中大多数过程开始时“魔术栈分配”指令的目的。到目前为止的示例通常在栈上保留了 64 或 256 字节的存储空间(分别足够存储 8 到 32 个 64 位参数)。

像在 macOS 上运行的 printf() 这样的函数,通过栈传递参数时,可以在调用函数之前将数据存储到这个区域。返回函数时,代码无需担心清理参数,因为该栈空间现在可以供下一个需要栈参数的函数使用。

当然,最终这些参数必须从栈中释放。这发生在过程执行 leave 宏时(或手动将 FP 复制到 SP,这是 leave 扩展的一部分)。当使用 enter 和 leave 分配这些栈空间用于参数,以及过程可能需要的任何局部变量时,你只需要分配和释放栈空间一次,而不是为每个单独的过程调用分配和释放。

如果你的程序没有任何局部变量,你可以像以下代码那样轻松分配栈空间来存储参数:

proc  myProc
enter 64    // Allocate 64 bytes for parameter usage.
  .
  .
  .
leave       // Deallocate storage and returns.
endp  myProc

如果你的程序需要局部变量存储,只需将额外的栈空间作为虚拟局部变量放在局部变量声明的末尾:

proc   myProc

locals mp
word   mp.local1
dword  mp.local2
byte   mp.local3
byte   mp.stack, 64 // Allocate 64 bytes for parms.
endl   mp

enter  mp.size      // Allocate locals and stack space.
  .
  .
  .
leave               // Deallocate storage and returns.
endp   myProc

请记住,enter 总是分配 16 字节的倍数空间,因此我们知道栈存储会对齐到 16 字节边界。

5.6.3.5 向 C/C++ 的 printf() 函数传递参数

在 Linux 下,你通过寄存器传递前八个 printf() 参数,就像你处理任何其他非变参函数一样。而在 macOS 下,这些参数总是通过栈传递,每个参数占一个双字(dword)。直到现在,本书使用了 vparmsn 宏来处理传递参数方式的差异(当然,也为了避免处理栈,因为本书在前几章并未涉及栈的相关内容)。

本书的写作目标是编写可在 Linux 和 macOS 之间移植的代码,仅在必要时使用特定于操作系统的代码;这也是在调用 printf() 时使用 vparmsn 宏的动机之一。现在你已经了解了这两种操作系统如何传递变参,可能会想用比 vparmsn 宏更灵活的方式来传递参数。不过,编写可移植代码有很大的好处(至少对本书中的源代码而言)。幸运的是,通过一些技巧,完全可以在不使用 vparmsn 宏的情况下,直接将参数传递给 printf(),并且让代码在这两种操作系统上都能组装和运行。

第一个规则是将每个 printf() 的参数加载到 X0 到 X7 寄存器中。这会将参数放置到 Linux 期望的位置。一旦参数进入这些寄存器,你还需要将它们存储到栈的存储区(SP + 0、SP + 8、SP + 16、...、SP + 56),这是 macOS 期望它们的位置。下面是一个典型的 printf() 调用,打印 X0、X5 和 X7 中的值:

locals mp
Byte   mp.stack, 24
endl   mp
 .
 .
 .
enter mp.size
 .
 .
 .
mov  x1, x0         // Put data in appropriate registers first.
mov  x2, x5
mov  x3, x7
lea  x0, fmtStr
str  x1, [sp]       // For macOS, store the arguments
str  x2, [sp, #8]   // onto the stack in their
str  x3, [sp, #16]  // appropriate locations.
bl   printf         // Then call printf.

严格来说,在 Linux 环境下运行时,str 指令并不是必需的。为了生成稍微更高效的代码,我在 aoaa.inc 包含文件中提供了以下的 mstr 宏:

mstr register, memory

这个宏在 Linux 下不会生成任何代码,而在 macOS 下则会生成相应的 str 指令。如果你使用 mstr 重写之前的代码,它在 Linux 下不会生成任何多余的代码:

locals mp
Byte   mp.stack, 24
endl   mp
 .
 .
 .
enter mp.size
 .
 .
 .
mov  x1, x0         // Put data in appropriate registers first.
mov  x2, x5
mov  x3, x7
lea  x0, fmtStr
mstr x1, [sp]       // For macOS, store the arguments
mstr x2, [sp, #8]   // onto the stack in their
mstr x3, [sp, #16]  // appropriate locations.
bl   printf         // Then call printf.

当然,如果你只为 Linux 编写代码,并且完全不关心 macOS 的移植性,你可以完全去掉 mstr 指令,从而减少一些杂乱的代码。

5.6.4 访问栈上的引用参数

因为你传递的是对象的地址作为引用参数,所以在过程内部访问引用参数比访问值参数稍微困难一些,你必须解引用引用参数的指针。

考虑列表 5-11,它演示了一个单一的引用传递参数。

// Listing5-11.S
//
// Accessing a reference parameter on the stack

#include "aoaa.inc"

            .data
value1:     .word   20
value2:     .word   30

ttlStr:     .asciz  "Listing 5-11"
fmtStr1:    .asciz  "Value of reference parameter: %d\n"

            .code
            .extern printf

// getTitle
//
// Return program title to C++ program.

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// refParm
//
// Expects a pass-by-reference parameter on the stack

            proc    refParm

            args    rp
            dword   rp.theParm
            enda    rp

            enter   64              // Alloc space for printf.

            lea     x0, fmtStr1
          ❶ ldr     x1, [fp, #rp.theParm]
            ldr     w1, [x1]
 ❷ mstr    x1, [sp]
            bl      printf

            leave
            endp    refParm

// Here is the asmMain function:

            proc    asmMain, public
            enter   64

// Pass the address of the arguments on the
// stack to the refParm procedure:

          ❸ lea     x0, value1
            str     x0, [sp]        // Store address on stack.
            bl      refParm

            lea     x0, value2
            str     x0, [sp]        // Store address on stack.
            bl      refParm

            leave

            endp    asmMain

refParm 过程将引用参数(一个 64 位指针)加载到 X1 1 中,然后立即通过获取 X1 地址中的 32 位字来解引用此指针。mstr 宏 ❷ 将第二个参数存储到栈中(在 macOS 下)。为了将一个变量通过引用传递给 refParm ❸,你必须计算它的有效地址并将其传递过去。

下面是列表 5-11 中程序的构建命令和示例输出:

$ ./build Listing5-11
$ ./Listing5-11
Calling Listing5-11:
Value of reference parameter: 20
Value of reference parameter: 30
Listing5-11 terminated

如你所见,访问(小型)按引用传递的参数比访问值参数效率稍低,因为你需要额外的指令将地址加载到 64 位指针寄存器中(更不用说你还需要为此目的保留一个 64 位寄存器)。如果频繁访问引用参数,这些额外的指令会累积起来,降低程序的效率。

此外,容易忘记解引用引用参数并在计算中使用值的地址。因此,除非你确实需要影响实际参数的值,否则你应该使用按值传递将小对象传递给过程。

传递大型对象,如数组和记录,是使用引用参数变得高效的地方。当按值传递这些对象时,调用代码必须复制实际参数;如果是大型对象,复制过程可能效率低下。因为计算大型对象的地址与计算小型标量对象的地址一样高效,所以按引用传递大型对象时不会丧失效率。在过程内,你仍然需要解引用指针以访问对象,但与复制大型对象的成本相比,由间接访问引起的效率损失可以忽略不计。

示例 5-12 演示了如何使用按引用传递来初始化结构体数组。

// Listing5-12.S
//
// Passing a large object by reference

#include "aoaa.inc"

NumElements =       24

// Here's the structure type:

            struct  Pt
            byte    pt.x
            byte    pt.y
            ends    Pt

            .data

ttlStr:     .asciz  "Listing 5-12"
fmtStr1:    .asciz  "refArrayParm[%d].x=%d"
fmtStr2:    .asciz  "refArrayParm[%d].y=%d\n"

            .code
            .extern printf

// getTitle
//
// Return program title to C++ program.

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// refAryParm
//
// Passed the address of an array of Pt structures
// Initializes each element of that array

            proc    refAryParm

 args    rap
            dword   rap.ptArray     // Reference parameter
            enda    rap

            enter   0               // No stack space needed!

// Get the base address of the array into X1:

          ❶ ldr     x1, [fp, #rap.ptArray]

// While X0 < NumElements, initialize each
// array element. x = X0/8, y = X0 % 8:

            mov     x0, xzr             // Index into array.
ForEachEl:  cmp     x0, #NumElements    // While we're not done
            bhs     LoopDone

// Compute address of ptArray[X0].
// Element adrs = base address (X1) + index (X19) * size (2):

          ❷ add     x3, x1, x0, lsl #1  // X3 = X1 + X0 * 2

// Store index / 8 into x field:

            lsr     x2, x0, #3          // X2 = X0 / 8
            strb    w2, [x3, #pt.x]     // ptArray[X0].x = X0/8

// Store index % 8 (mod) into y field:

            and     x2, x0, #0b111      // X2 = X0 % 8
            strb    w2, [x3, #pt.y]     // ptArray[X0].y = X0 % 8

// Increment index and repeat:

            add     x0, x0, #1
            b.al    ForEachEl

LoopDone:   leave
            endp    refAryParm

// Here is the asmMain function:

            proc    asmMain, public

// Easier to access local variables than globals, so let's
// make everything a local variable:

            locals  am
            word    saveX19
            byte    Pts, NumElements * (Pt.size)
            byte    stackSpace, 64
            endl    am

            enter   am.size             // Reserve space.

 str     x19, [fp, #saveX19] // Save nonvolatile reg.

// Initialize the array of points:

          ❸ add     x0, fp, #Pts    // Compute address of Pts.
            str     x0, [sp]        // Pass address on stack.
            bl      refAryParm

// Display the array:

            mov     x19, xzr        // X19 is loop counter.
dispLp:     cmp     x19, #NumElements
            bhs     dispDone

// Print the x field:

            lea     x0, fmtStr1
            mov     x1, x19
            mstr    x1, [sp]
            add     x3, fp, #Pts         // Get array base address.
            add     x3, x3, x19, lsl #1  // Index into array.
            ldrb    w2, [x3, #pt.x]      // Get ptArray[X0].x.
            mstr    x2, [sp, #8]
            bl      printf

// Print the y field:

            lea     x0, fmtStr2
            mov     x1, x19
            mstr    x1, [sp]
            add     x3, fp, #Pts         // Get array base address.
            add     x3, x3, x19, lsl #1  // Index into array.
            ldrb    w2, [x3, #pt.y]      // Get ptArray[X0].x.
            mstr    x2, [sp, #8]
            bl      printf

// Increment index and repeat:

            add     x19, x19, #1
            b.al    dispLp

dispDone:
            ldr     x19, [fp, #saveX19]  // Restore X19.
            leave
            endp    asmMain

该代码计算了 Pts 数组的地址,并将该数组(按引用)传递给 refAryParm 过程❸。它将该地址加载到 X1 ❶,并使用该指针值作为 refAryParm 处理的数组的基地址❷。

这是构建命令和示例输出:

$ ./build Listing5-12
$ ./Listing5-12
Calling Listing5-12:
refArrayParm[0].x=0 refArrayParm[0].y=0
refArrayParm[1].x=0 refArrayParm[1].y=1
refArrayParm[2].x=0 refArrayParm[2].y=2
refArrayParm[3].x=0 refArrayParm[3].y=3
refArrayParm[4].x=0 refArrayParm[4].y=4
refArrayParm[5].x=0 refArrayParm[5].y=5
refArrayParm[6].x=0 refArrayParm[6].y=6
refArrayParm[7].x=0 refArrayParm[7].y=7
refArrayParm[8].x=1 refArrayParm[8].y=0
refArrayParm[9].x=1 refArrayParm[9].y=1
refArrayParm[10].x=1 refArrayParm[10].y=2
refArrayParm[11].x=1 refArrayParm[11].y=3
refArrayParm[12].x=1 refArrayParm[12].y=4
refArrayParm[13].x=1 refArrayParm[13].y=5
refArrayParm[14].x=1 refArrayParm[14].y=6
refArrayParm[15].x=1 refArrayParm[15].y=7
refArrayParm[16].x=2 refArrayParm[16].y=0
refArrayParm[17].x=2 refArrayParm[17].y=1
refArrayParm[18].x=2 refArrayParm[18].y=2
refArrayParm[19].x=2 refArrayParm[19].y=3
refArrayParm[20].x=2 refArrayParm[20].y=4
refArrayParm[21].x=2 refArrayParm[21].y=5
refArrayParm[22].x=2 refArrayParm[22].y=6
rRefArrayParm[23].x=2 refArrayParm[23].y=7
Listing5-12 terminated

此输出显示了 refAryParm 过程是如何初始化数组的。

5.7 函数及其返回结果

函数是将结果返回给调用者的过程。在汇编语言中,过程和函数之间的语法差异很小。这就是为什么aoaa.inc没有为函数提供特定宏声明的原因。然而,语义上仍然存在差异;虽然你可以在 Gas 中以相同的方式声明它们,但使用方式却不同。

过程是一系列完成任务的机器指令。过程执行的最终结果是完成该活动。而函数则执行一系列机器指令,专门计算一个值并返回给调用者。当然,函数也可以执行某些活动,过程也可以计算值,但主要的区别是函数的目的是返回一个计算结果;过程则没有这个要求。

在汇编语言中,你不会使用特殊语法来专门定义一个函数。在 Gas 中,一切都是过程。当程序员明确决定通过过程的执行返回函数结果时,代码段才变成函数。

寄存器是返回函数结果最常见的地方。C 标准库中的 strlen() 函数就是一个很好示例,它在 CPU 的一个寄存器中返回一个值。它会将字符串的长度(你作为参数传递的字符串地址)返回在 X0 寄存器中。

按照惯例,程序员会尝试将 8 位、16 位和 32 位的结果返回在 W0 寄存器中,而将 64 位值返回在 X0 寄存器中。这是大多数高级语言(HLL)返回这些类型结果的地方,也是 ARM ABI 规范中规定返回函数结果的地方。例外的是浮点值;我会在第六章中讨论浮点函数结果。

W0/X0 寄存器并没有什么特别神圣的地方。如果更方便的话,你可以在任何寄存器中返回函数结果。当然,如果你调用的是符合 ARM ABI 规范的函数,比如 strlen(),那么你只能期待函数的返回结果在 X0 寄存器中。例如,strlen() 函数会将一个整数返回在 X0 中。

如果你需要返回一个大于 64 位的函数结果,显然必须将其返回到 X0 以外的地方(因为 X0 只能容纳 64 位值)。对于稍大于 64 位的值(例如 128 位,甚至可能是 256 位),你可以将结果分割成若干部分,并在两个或更多寄存器中返回这些部分。看到函数将 128 位值返回在 X1:X0 寄存器对中并不罕见。只是要记住,这些方案不符合 ARM ABI 规范,因此仅在调用你自己编写的代码时才实际可用。

如果你需要返回一个较大的对象作为函数结果(比如一个包含 1000 个元素的数组),显然你不可能将函数结果返回在寄存器中。当返回超过 64 位的函数结果时,ARM ABI 规范要求调用者为结果分配存储空间,并在 X8 中传递指向该存储空间的指针。函数将结果放入该存储空间,调用者在返回时从该位置检索数据。

5.8 递归

递归 是指一个过程调用它自身。例如,以下是一个递归过程:

proc  Recursive
enter 0
bl    Recursive
leave
endp  Recursive

当然,CPU 永远不会从这个过程返回。在进入 Recursive 时,这个过程会立即再次调用自身,控制永远不会传递到过程的末尾。在这种情况下,失控的递归会导致逻辑上的无限循环,进而产生栈溢出,此时操作系统会引发异常并停止程序。

类似于循环结构,递归需要一个终止条件来避免无限递归。递归可以在加上终止条件后重写如下:

 proc  Recursive
          enter 0
 subs  x0, x0, #1
          beq   allDone
          bl    Recursive
allDone:
          leave
          endp  Recursive

对该例程的修改使得 Recursive 按照 X0 寄存器中显示的次数调用自身。每次调用时,Recursive 将 X0 寄存器减 1,然后再次调用自身。最终,Recursive 将 X0 减到 0 并从每个调用中返回,直到它返回到原始调用者。

到目前为止,本节中还没有真正需要递归的地方。毕竟,你可以高效地按照以下方式编写这个过程:

 proc  Recursive
          enter 0
iterLp:
          subs  x0, x0, #1
          bne   iterLp
          leave
          endp  Recursive

这两个最后的例子会根据 X0 寄存器传入的次数重复执行过程的主体。(后一种版本会显著更快,因为它没有 bl/ret 指令的开销。)事实证明,并不是所有的递归算法都能以迭代的方式实现。然而,许多递归实现的算法比其迭代版本更高效,而且大多数情况下递归形式的算法更容易理解。

快速排序算法可能是最著名的通常以递归形式出现的算法。列表 5-13 展示了该算法的 Gas 实现。

// Listing5-13.S
//
// Recursive quicksort

#include "aoaa.inc"

numElements =       10

            .data
ttlStr:     .asciz  "Listing 5-13"
fmtStr1:    .asciz  "Data before sorting: \n"
fmtStr2:    .ascii  "%d"   // Use nl and 0 from fmtStr3
fmtStr3:    .asciz  "\n"
fmtStr4:    .asciz  "Data after sorting: \n"
fmtStr5:    .asciz  "ary=%p, low=%d, high=%d\n"

theArray:   .word   1,10,2,9,3,8,4,7,5,6

 .code
            .extern printf

// getTitle
//
// Return program title to C++ program.

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// quicksort
//
// Sorts an array using the quicksort algorithm
//
// Here's the algorithm in C, so you can follow along:
//
// void quicksort(int a[], int low, int high)
// {
//     int i,j,Middle;
//     if(low < high)
//     {
//         Middle = a[(low + high)/2];
//         i = low;
//         j = high;
//         do
//         {
//             while(a[i] <= Middle) i++;
//             while(a[j] > Middle) j--;
//             if(i <= j)
//             {
//                 swap(a[i],a[j]);
//                 i++;
//                 j--;
//             }
//         } while(i <= j);
//
//         // Recursively sort the two subarrays:
//
//         if(low < j) quicksort(a,low,j);
//         if(i < high) quicksort(a,i,high);
//     }
//}
//
// Args:
//    X19 (_a):       Pointer to array to sort
//    X20 (_lowBnd):  Index to low bound of array to sort
//    X21 (_highBnd): Index to high bound of array to sort
//
// Within the procedure body, these registers
// have the following meanings:
//
// X19: Pointer to base address of array to sort
// X20: Lower bound of array (32-bit index)
// X21: Higher bound of array (32-bit index)
//
// X22: index (i) into array
// X23: index (j) into array
// X24: Middle element to compare against
//
// Create definitions for variable names as registers
// to make the code more readable:

#define array x19
#define lowBnd x20
#define highBnd x21
#define i x22
#define j x23
#define middle w24

            proc    quicksort

            locals  qsl
            dword   qsl.saveX19
            dword   qsl.saveX20
            dword   qsl.saveX21
            dword   qsl.saveX22
            dword   qsl.saveX23
            dword   qsl.saveX24
            dword   qsl.saveX0
            byte    qsl.stackSpace, 32
            endl    qsl

            enter   qsl.size

// Preserve the registers this code uses:

            str     x0, [fp, #qsl.saveX0]
            str     x19, [fp, #qsl.saveX19]
            str     x22, [fp, #qsl.saveX22]
            str     x23, [fp, #qsl.saveX23]
            str     x24, [fp, #qsl.saveX24]

            cmp     lowBnd, highBnd
            bge     endif3

            mov     i, lowBnd        // i = low
            mov     j, highBnd       // j = high

// Compute a pivotal element by selecting the
// physical middle element of the array:
//
// Element address = ((i + j) / 2) * 4 (4 is element size)
//                 = ((i + j) * 2)

            add     x0, i, j
            lsr     x0, x0, #1

// Middle = ary[(i + j) / 2]:

            ldr     middle, [array, x0, lsl #2]

// Repeat until the i and j indices cross each
// other (i works from the start toward the end
// of the array, j works from the end toward the
// start of the array):

rptUntil:

// Scan from the start of the array forward,
// looking for the first element greater or equal
// to the middle element:

            sub     i, i, #1        // To counteract add, below
while1:     add     i, i, #1        // i = i + 1
            ldr     w1, [array, i, lsl #2]
            cmp     middle, w1      // While middle <= ary[i]
            bgt     while1

// Scan from the end of the array backward, looking
// for the first element that is less than or equal
// to the middle element:

            add     j, j, #1     // To counteract sub, below
while2:     sub     j, j, #1     // j = j - 1
            ldr     w1, [array, j, lsl #2]
            cmp     middle, w1   // while middle >= a[j]
            blt     while2

// If you've stopped before the two pointers have
// passed over each other, you have two
// elements that are out of order with respect
// to the middle element, so swap these two elements:

            cmp     i, j        // If i <= j
            bgt     endif1

            ldr     w0, [array, i, lsl #2]
            ldr     w1, [array, j, lsl #2]
            str     w0, [array, j, lsl #2]
            str     w1, [array, i, lsl #2]

            add     i, i, #1
            sub     j, j, #1

endif1:     cmp     i, j        // Until i > j
            ble     rptUntil

// The code has just placed all elements in the array in
// their correct positions with respect to the middle
// element of the array. Unfortunately, the
// two halves of the array on either side of the pivotal
// element are not yet sorted. Call quicksort recursively
// to sort these two halves if they have more than one
// element in them (if they have zero or one elements,
// they are already sorted).

            cmp     lowBnd, j   // If lowBnd < j
            bge     endif2

            // Note: a is still in X19,
            // Low is still in X20.

            str     highBnd, [fp, #qsl.saveX21]
            mov     highBnd, j
            bl      quicksort   // (a, low, j)
            ldr     highBnd, [fp, #qsl.saveX21]

endif2:     cmp     i, highBnd  // If i < high
            bge     endif3

            // Note: a is still in X19,
            // High is still in X21.

            str     lowBnd, [fp, #qsl.saveX20]
            mov     lowBnd, i
            bl      quicksort   // (a, i + 1, high)
            ldr     lowBnd, [fp, #qsl.saveX20]

// Restore registers and leave:

endif3:
            ldr     x0,  [fp, #qsl.saveX0]
            ldr     x19, [fp, #qsl.saveX19]
            ldr     x22, [fp, #qsl.saveX22]
            ldr     x23, [fp, #qsl.saveX23]
            ldr     x24, [fp, #qsl.saveX24]
            leave
            endp    quicksort

// printArray
//
// Little utility to print the array elements

            proc    printArray

            locals  pa
            dword   pa.saveX19
            dword   pa.saveX20
            endl    pa

            enter   pa.size
            str     x19, [fp, #pa.saveX19]
            str     x20, [fp, #pa.saveX20]

            lea     x19, theArray
            mov     x20, xzr
whileLT10:  cmp     x20, #numElements
            bge     endwhile1

            lea     x0, fmtStr2
            ldr     w1, [x19, x20, lsl #2]
            mstr    w1, [sp]
            bl      printf

            add     x20, x20, #1
            b.al    whileLT10

endwhile1:  lea     x0, fmtStr3
            bl      printf

            ldr     x19, [fp, #pa.saveX19]
            ldr     x20, [fp, #pa.saveX20]
            leave
            endp    printArray

// Here is the asmMain function:

            proc    asmMain, public

            locals  am
            dword   am.savex19
            dword   am.savex20
            dword   am.savex21
            byte    am.stackSpace, 64
            endl    am

            enter   am.size

            str     array, [fp, #am.saveX19]
            str     lowBnd, [fp, #am.saveX20]
            str     highBnd, [fp, #am.saveX21]

// Display unsorted array:

            lea     x0, fmtStr1
            bl      printf
            bl      printArray

// Sort the array:

            lea     array, theArray
            mov     lowBnd, xzr               // low = 0
            mov     highBnd, #numElements - 1 // high = 9
            bl      quicksort                 // (theArray, 0, 9)

// Display sorted results:

            lea     x0, fmtStr4
            bl      printf
            bl      printArray

 ldr     array, [fp, #am.saveX19]
            ldr     lowBnd, [fp, #am.saveX20]
            ldr     highBnd, [fp, #am.saveX21]
            leave
            endp    asmMain

以下是列表 5-13 的构建命令和输出:

$ ./build Listing5-13
$ ./Listing5-13
Calling Listing5-13:
Data before sorting:
1
10
2
9
3
8
4
7
5
6

Data after sorting:
1
2
3
4
5
6
7
8
9
10

Listing5-13 terminated

该输出展示了数组在排序之前和经过快速排序过程后排序后的内容。

5.9 过程指针和过程参数

ARM 的 bl 指令支持间接形式:blr。该指令的语法如下:

blr `reg`64  // Indirect call through `reg`64

这个指令从指定的寄存器中获取一个过程的第一条指令的地址。它等效于以下伪指令:

add lr, pc, #4  // Set LR to return address (PC is pointing at mov).
mov pc, `reg`64 // Transfer control to specified procedure.

Gas 将过程名称视为静态对象。因此,你可以通过使用 lea 宏和过程名称来计算一个过程的地址。例如

lea x0, procName

procName过程的第一条指令的地址加载到 X0 寄存器中。以下代码序列最终会调用procName过程:

lea x0, procName
blr x0

因为过程的地址适合存储在一个 64 位对象中,你可以将该地址存储到一个双字变量中;事实上,你可以通过以下代码将过程的地址初始化到双字变量中:

 proc  p
        .
        .
        .
       endp  p
        .
        .
        .
       .data
ptrToP:
       .dword  p
        .
        .
        .
       lea  x0, ptrToP
       ldr  x0, [x0]
       blr  x0    // Calls p if ptrToP has not changed

请注意,尽管 macOS 不允许你在.text 段中使用外部地址初始化一个 dword 变量,但它允许你使用.text 段内某些代码的地址初始化一个指针(无论在任何段中)。

和所有指针对象一样,除非你已经用合适的地址初始化了指针变量,否则不要尝试通过指针变量间接调用一个过程。你可以通过两种方式初始化过程指针变量:你可以在.data、.text 和.rodata 段中创建带有初始化器的 dword 变量,或者你可以计算一个例程的地址(作为 64 位值)并在运行时将该 64 位地址直接存储到过程指针中。以下代码片段演示了两种初始化过程指针的方法:

 .data
ProcPointer: .dword  p    // Initialize ProcPointer with
                          // the address of p.
 .
              .
              .
             lea  x0, ProcPointer
             ldr  x0, [x0]
             blr  x0  // First invocation calls p.

// Reload ProcPointer with the address of q:

             lea  x0, q
             lea  x1, ProcPointer
             str  x0, [x1]
              .
              .
              .
             lea  x0, ProcPointer
             ldr  x0, [x0]
             blr  x0  // This invocation calls q.

尽管本节中的所有示例都使用静态变量声明(.data、.text、.bss 和 .rodata),但你并不限于在静态变量声明部分声明简单的程序指针。你还可以将程序指针(它们只是双字变量)声明为局部变量、作为参数传递,或者将它们声明为记录或联合体的字段。

程序指针在参数列表中也非常宝贵。通过传递程序的地址来选择多个程序中的一个进行调用是一种常见操作。过程参数仅仅是一个包含程序地址的双字参数,因此传递一个过程参数实际上与使用局部变量保存程序指针没有什么不同(当然,调用者会用间接调用的程序地址来初始化这个参数)。

5.10 程序定义的栈

使用预索引和后索引寻址模式,再配合 ARM 的 64 位寄存器,可以创建不使用 SP 寄存器的软件控制栈。由于 ARM CPU 提供了硬件栈指针寄存器,可能并不明显为什么你要考虑使用另一个栈。正如你所学到的,ARM 硬件栈的一个限制是它必须始终保持 16 字节对齐。返回地址和你可能希望保留在栈上的其他值通常为 8 字节或更小。例如,不能单独将 LR 寄存器压入栈中,否则会引发总线错误。然而,如果你创建自己的栈,就不会遇到这个问题。

也许你在想,为什么会有人在程序中使用第二个栈。如果正常的硬件栈能正常工作,为什么要增加第二个栈的复杂性?在几种情况下,拥有两个栈是有用的。特别是协程、生成器和迭代器可以利用额外的栈指针。请参阅第 5.12 节“更多信息”(第 290 页)中的 Wikipedia 链接了解更多相关信息。当然,正如前面所指出的,不必将栈指针对齐到 16 字节是使用程序定义栈的另一个好理由。

创建自己的栈有两个缺点:你必须为此目的专门分配一个 ARM 的寄存器,并且必须显式地为该栈分配存储空间(操作系统在运行程序时会自动分配硬件栈)。

你可以轻松地在 .data 段中分配存储空间。一个典型的栈至少会有 128 到 256 字节的存储空间。以下是一个分配 256 字节栈的简单示例:

 .data
smallStk:    .fill  256
endSmallStk:

如果在你的程序中使用自动变量,可能需要超过 256 字节的存储空间;请参见第 5.4.1 节“激活记录”(第 244 页)和第 5.5 节“局部变量”(第 250 页)。

通常,栈从分配的内存空间的末端开始,并向较小的内存地址方向增长。在这个例子中,将 endSmallStk 标签放在栈的末端,给你一个句柄来初始化栈指针。

由于 ARM 使用 SP 作为硬件栈指针,因此你必须为程序定义的栈指针使用不同的寄存器。这个寄存器需要是一个非易失性寄存器——你不希望像 printf()这样的函数调用干扰你的栈。由于 X30 已被用于 LR,X29 保留用于 FP(参见第一章),所以 X28 是一个很好的用户定义栈指针(USP)选择。你可以将它初始化为指向 smallStk 末尾,如下所示:

#define usp  x28   // Use a reasonable name for the user SP.
         .
         .
         .
        lea usp, endSmallStk

这样,USP 寄存器将指向栈的末端,这正是你希望的;栈指针应指向当前栈顶,当栈为空时,正如初始化后那样,栈指针不会指向有效的栈地址。

要在栈上推送和弹出数据,可以使用相同的 str 和 ldr 指令,以及预索引和后索引寻址模式,就像操作硬件栈一样。唯一的区别是你需要指定 USP 寄存器(X28),并且不必保持栈对齐到 16 字节(实际上,技术上你不需要保持对齐,但如果你保持对齐到字或双字,速度会更快)。下面是如何将 LR 寄存器推送到用户栈并弹出:

str lr, [usp, #-8]!  // Pre-decrement addressing mode
 .
 .
 .
ldr lr, [usp], #8    // Post-increment addressing mode
ret

清单 5-14 是使用软件栈重写清单 5-4。

// Listing5-14.S
//
// Demonstrating a software stack

#include "aoaa.inc"

#define usp x28     // Program-defined stack pointer

stackSpace  =           64  // Space on the HW stack
saveLRUSP   =           48  // 16 bytes to hold LR and USP

            .section    .rodata, ""
ttlStr:     .asciz      "Listing 5-14"
space:      .asciz      " "
asterisk:   .asciz      "*, %ld\n"

            .data
loopIndex:  .dword       .-.     // Used to print loop index value

// Here's the software-based stack this program will use
// to store return addresses and the like:

            .align      3
smallStk:   .fill       256, .-.
endSmallStk:

            .code
            .extern     printf

// getTitle
//
// Return program title to C++ program.

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// print40Spaces
//
// Prints out a sequence of 40 spaces
// to the console display

            proc    print40Spaces
          ❶ stp     lr, x19, [usp, #-16]! // Preserve LR and X19.

            mov     w19, #40
printLoop:  lea     x0, space
            bl      printf
 subs    w19, w19, #1
            bne     printLoop // Until w19 == 0

          ❷ ldp     lr, x19, [usp], #16 // Restore LR and X19.
            ret
            endp    print40Spaces

// Here is the asmMain function:

            proc    asmMain, public
          ❸ sub     sp, sp, #stackSpace       // HW stack space
            stp     lr, usp, [sp, #saveLRUSP] // Save on HW stack.

          ❹ lea     usp, endSmallStk   // Initialize USP.
          ❺ str     x19, [usp, #-16]!  // Save X19 on SW stk.

            mov     x19, #20
astLp:      bl      print40Spaces
            lea     x0, loopIndex
            str     x19, [x0]
            lea     x0, asterisk
            vparm2  loopIndex
            bl      printf
            subs    x19, x19, #1
            bne     astLp

          ❻ ldr     x19, [usp], #16  // Restore from SW stack.
          ❼ ldp     lr, usp, [sp, #saveLRUSP]
            add     sp, sp, #stackSpace
            ret     // Returns to caller
            endp    asmMain

进入 print40Spaces 时,代码将 LR 和 X19 寄存器推送到软件栈 ❶,使用 stp 指令同时保存这两个寄存器。预索引寻址模式将 USP 减少 16;然后该指令将两个 8 位寄存器存储到软件栈中。返回之前,print40Spaces 使用 lpd 指令和后索引寻址模式从软件栈恢复 LR 和 X19 寄存器 ❷。

尽管这个程序演示了如何使用软件控制的栈,但它仍然需要在几个目的上使用硬件栈。特别是,printf()函数会将其返回地址(以及参数)推送到硬件栈中。因此,主程序为此目的在硬件栈上设置了存储空间 ❸。程序还必须在初始化 USP 寄存器指向 smallStack 数据区末尾之前,保存 USP 寄存器(X28)。刚分配的硬件栈空间正是保存它的理想位置。只要代码在此处保存 USP 寄存器,它也可以同时保存 LR 寄存器,因为你必须始终向硬件栈写入 16 字节。

一旦代码保存了 USP 的值(因为它是一个非易失性寄存器),下一步是用小栈内存缓冲区末尾的地址初始化 USP ❹。将 endSmallStk 的地址加载到 USP 中即可完成此操作。栈初始化后,代码可以使用它;例如,以下语句将非易失性寄存器 X19 推送到软件栈中 ❺(以便为 C++ 程序保存它)。

离开之前,代码从软件栈中弹出 X19 非易失性寄存器,以恢复其值 ❻。最后,主程序从硬件栈中恢复 USP 和 LR(并清理已分配的存储),然后再返回 C++ 代码 ❼。

为了证明它确实有效,以下是 Listing 5-14 中程序的构建命令和示例输出:

$ ./build Listing5-14
$ ./Listing5-14
Calling Listing5-14:
                                        *, 20
                                        *, 19
                                        *, 18
                                        *, 17
                                        *, 16
                                        *, 15
                                        *, 14
                                        *, 13
                                        *, 12
                                        *, 11
                                        *, 10
                                        *, 9
                                        *, 8
                                        *, 7
                                        *, 6
                                        *, 5
                                        *, 4
                                        *, 3
                                        *, 2
                                        *, 1
Listing5-14 terminated

如您所见,Listing 5-14 产生的输出与 Listing 5-4 相同。

5.11 继续前进

本章涵盖了大量内容,包括汇编语言编程风格的介绍、基本的 Gas 过程语法、本地标签、过程的调用与返回、寄存器保护、激活记录、函数结果等。有了这些信息,您已准备好在下一章学习如何编写用于计算算术结果的函数。

5.12 更多信息

第六章:6 算术运算

本章讨论了汇编语言中的算术运算,包括 ARM 处理器上的浮点运算和对实数运算的架构支持。在本章结束时,你应该能够将高阶语言(如 Pascal、Swift 和 C/C++)中的算术表达式和赋值语句转换为 ARM 汇编语言。你将学习如何将浮点值作为参数传递给过程,并将实数值作为函数结果返回。

6.1 额外的 ARM 算术指令

在学习如何在汇编语言中编码算术表达式之前,你应该先学习 ARM 指令集中的其他算术指令。前面的章节已经涵盖了大部分的算术和逻辑指令,因此本节将介绍剩余的少数几条指令。

6.1.1 乘法

第四章简要介绍了使用 mul 和 madd 指令进行的乘法。作为提醒,这些指令如下:

mul   X`d`, X`s`1, X`s`2       // X`d` = X`s`1 * X`s`2
madd  X`d`, X`s`1, X`s`2, X`s`3  // X`d` = X`s`1 * X`s`2 + X`s`3

只要没有发生溢出,这些指令对无符号和有符号乘法都能产生正确的结果。

这些指令将两个 64 位整数相乘并产生一个 64 位的结果。两个n位数相乘实际上可能会产生一个 2 × n位的结果,这意味着将两个 64 位寄存器相乘可能会产生一个最多 128 位的结果。这些指令会忽略任何溢出,并且只保留结果的低 64 位(第八章讨论了如何生成完整的 128 位结果,如果你需要的话)。

你还可以为这两条指令指定 32 位寄存器:

mul   W`d`, W`s`1, W`s`2       // W`d` = W`s`1 * W`s`2
madd  W`d`, W`s`1, W`s`2, W`s`3  // W`d` = W`s`1 * W`s`2 + W`s`3

这些指令会生成 32 位结果,忽略任何溢出。还有两条额外的乘法指令:乘法并减法、乘法并取负:

msub  W`d`, W`s`1, W`s`2, W`s`3   // W`d` = W`s`1 * W`s`2 - W`s`3
msub  X`d`, X`s`1, X`s`2, X`s`3   // X`d` = X`s`1 * X`s`2 + X`s`3
mneg  W`d`, W`s`1, W`s`2        // W`d` = -(W`s`1 * W`s`2)
mneg  X`d`, X`s`1, X`s`2        // X`d` = -(X`s`1 * X`s`2)

和之前的指令一样,这些乘法操作会忽略 32 位或 64 位之外的溢出。

ARM 不提供会影响条件码标志的乘法指令。这些指令没有带有 s 后缀的版本。

6.1.2 除法和取模

ARM64 CPU 仅提供两条除法指令:

sdiv  X`d`, X`s`1, X`s`2  // X`d` = X`s`1 / X`s`2 (signed division)
udiv  X`d`, X`s`1, X`s`2  // X`d` = X`s`1 / X`s`2 (unsigned division)

与乘法不同,对于有符号和无符号整数值,你必须使用不同的指令。

除法有两个特殊情况需要考虑:除以 0 和将最小的负数除以–1(在数学上会导致溢出)。除以 0 的结果是 0,但没有任何错误提示。将 0x8000000000000000(最小的 64 位负数)除以 0xFFFFFFFFFFFFFFFF(–1)的有符号除法(sdiv)会得到 0x8000000000000000,同样也不会提示错误。32 位除法也会得到类似的结果:0x80000000 / 0xFFFFFFFF。在除法前,你必须明确检查这些操作数,以便捕捉这些错误。

ARM64 CPU 上没有单一指令来计算除法操作后的余数。您可以通过组合除法和乘法操作来计算余数:

mod(x0, x1) = x0 - (x0 / x1) * x1

或者,您可以通过使用以下两条指令来计算相同的结果:

udiv x2, x0, x1
msub x3, x2, x1, x0

在此序列之后,X2 和 X3 保存以下值

x2 = x0 / x1
x3 = x0 % x1  // % is C modulo (remainder) operator.

从而在 X3 中提供模运算结果。

6.1.3 cmp 操作回顾

如第 2.10.4 节“cmp 和相应的条件分支”中所述,见 第 78 页,cmp 指令会根据减法操作(左操作数 - 右操作数)的结果更新 ARM 的标志。根据 ARM 设置标志的方式,您可以将此指令理解为“将左操作数与右操作数进行比较”。您可以通过使用条件分支指令来测试比较的结果(有关条件分支的详细内容,请参见第二章,有关控制结构实现的更多内容,请参见第七章)。

在探索 cmp 时,一个好的起点是准确查看它如何影响标志。考虑以下 cmp 指令:

cmp w0, w1

这条指令执行 W0 - W1 的计算,并根据计算结果设置标志。标志的设置如下:

Z    当且仅当 W0 = W1 时,零标志会被设置。这是 W0 – W1 产生零结果的唯一情况。因此,您可以使用零标志来测试相等或不相等。

N    如果结果为负数,负(符号)标志会被设置为 1。您可能认为只有当 W0 小于 W1 时该标志才会被设置,但事实并非总是如此。如果 W0 = 0x7FFFFFFFh 且 W1 = -1(0xFFFFFFFF),那么从 W0 中减去 W1 会得到 0x80000000,这是负数(因此负标志会被设置)。至少对于带符号比较,负标志不会包含正确的状态。对于无符号操作数,考虑 W0 = 0xFFFFFFFF 和 W1 = 1。在这里,W0 大于 W1,但它们的差值是 0xFFFFFFFEh,这仍然是负数。事实证明,负标志和溢出标志结合起来,可以用于比较两个带符号值。

V    在执行 cmp 操作后,如果 W0 和 W1 的差值导致符号溢出或下溢,溢出标志会被设置。如前所述,符号标志和溢出标志在执行带符号比较时都会被使用。

C    如果从 W0 中减去 W1 时需要借位(无符号溢出或下溢),则会设置进位标志。仅当 W0 小于 W1 且 W0 和 W1 都是无符号值时,才会发生这种情况。

表 6-1 显示了 cmp 指令在比较无符号或带符号值之后如何影响标志。

表 6-1:cmp 操作后的条件代码设置

标志 无符号结果 带符号结果
零(Z) 相等/不相等 相等/不相等
进位(C) 左边 ≥ 右边(C = 1) 左边 < 右边(C = 0) 无意义
溢出(V) 无意义 请参阅本节讨论
符号(N) 无意义 请参阅本节讨论

鉴于 cmp 指令以这种方式设置标志,你可以通过以下标志测试两个有符号操作数的比较:

cmp `Left`, `Right`

对于有符号比较,N 和 V 标志组合起来有以下含义:

  • 如果[N != V],则左操作数 < 右操作数(有符号比较)。

  • 如果[N == V],则左操作数 ≥ 右操作数(有符号比较)。

要理解为什么这些标志以这种方式设置,可以参考表 6-2 中的 32 位示例。这些值可以轻松地扩展为 64 位,结果是相同的。

表 6-2:减法后的符号和溢出标志设置(32 位值)

左操作数 减法 右操作数 N V
0xFFFFFFFF (–1) 0xFFFFFFFE (–2) 0 0
0x80000000 (–20 亿+) 0x000000001 0 1
0xFFFFFFFE (–2) 0xFFFFFFFF (–1) 1 0
0x7FFFFFFF (20 亿+) 0xFFFFFFFF (–1) 1 1

记住,cmp 操作实际上是减法;因此,表 6-2 中的第一个示例计算(–1)–(–2),结果是+1。结果为正,且没有发生溢出,因此 N 和 V 标志都是 0。因为(N == V),左操作数大于或等于右操作数。

cmp 指令在第二个示例中将计算(–2,147,483,648)–(+1),结果为(–2,147,483,649)。由于 32 位有符号整数无法表示此值,该值会回绕到 0x7FFFFFFF(+2,147,483,647),并设置溢出标志。结果为正(至少作为 32 位值),因此 CPU 会清除负标志。因为(N == V)在此情况下,左操作数小于右操作数。

在第三个示例中,cmp 计算(–2)–(–1),结果是(–1)。没有发生溢出,因此 V 为 0;结果为负数,因此 N 为 1。因为(N != V),左操作数小于右操作数。

在最后一个示例中,cmp 计算(+2,147,483,647)–(–1)。这会产生(+2,147,483,648),设置溢出标志。此外,值会回绕到 0x80000000(−2,147,483,648),因此负标志也被设置。因为(N == V)为 0,左操作数大于或等于右操作数。

cmn(比较负数)指令将第一个源操作数与取反后的第二个操作数进行比较;与 cmp 一样,它设置标志并忽略结果。它也像 cmp 一样,是另一个指令 add 的别名:

add wzr, W`s`1, W`s`2
add xzr, X`s`1, X`s`2

这是因为 cmp 等同于一个 sub 指令,使用 WZR/XZR 作为目标寄存器;当比较一个取反的值时,得到的表达式为 left – (–right),这在数学上等价于 left + right。

将 add 用作 cmn 的同义词存在一个问题:如果第二个(右侧)操作数为 0,add 并不会正确设置进位标志。因此,如果右侧操作数有可能是 0,你不能在 cmn 指令之后使用无符号条件码(hs、hi、ls 或 lo)。通常这不会是问题,因为按定义,cmn 用于比较有符号值,并且你应该在使用该指令后使用有符号条件。

可以说,cmn 存在的主要原因是操作数 2 的立即数值必须在 0 到 4,095 的范围内。你不能通过 cmp 指令将寄存器与负的立即数值进行比较。cmn 指令也仅限于范围 0 到 4,095 的常数,但它会在比较前对立即数值进行取反,从而允许在 –1 到 –4,095 的范围内使用负常数(–0 仍然为 0)。

6.1.4 条件指令

在原始的 32 位 ARM 架构中,大多数数据处理指令都是条件性的。你可以根据 PSTATE 条件码标志设置,选择性地执行诸如加法等指令。可惜的是,在 64 位模式下,测试 16 种可能条件所需的 4 位(与条件分支指令相同)被其他编码所占用。然而,条件指令执行仍然很有用,因此 ARM64 保留了一些常用的条件指令。

第一个条件指令是 csel(条件选择)

csel W`d`, W`s`1, W`s`2, `cond`  // if(`cond`) then W`d` = W`s`1 else W`d` = W`s`2 
csel X`d`, X`s`1, X`s`2, `cond`  // if(`cond`) then X`d` = X`s`1 else X`d` = X`s`2 

其中 cond 是以下条件规范之一

cs, cc, eq, ne, mi, pl, vs, vc, hs, hi, ls, lo, gt, ge, lt, le 

这些定义与条件分支指令的含义相同。

aoaa.inc 包含文件提供了以下相反条件的定义:

nhs, nhi, nls, nlo, ngt, nge, nlt, nle 

这些分别是 lo、ls、hi、hs、le、lt、ge 和 gt 的同义词。

正如其名称所示,csel 指令根据当前标志设置,选择两个源操作数中的一个并复制到目标寄存器。例如,以下指令

csel x0, x1, x2, eq 

如果零标志被设置,它将复制 X1 到 X0;否则,它将复制 X2 到 X0。

csinc 指令允许执行条件选择(如果条件为真)或递增(如果条件为假)操作:

csinc W`d`, W`s`1, W`s`2, `cond`  // if(`cond`) then W`d` = W`s`1 else W`d` = W`s`2 + 1 
csinc X`d`, X`s`1, X`s`2, `cond`  // if(`cond`) then X`d` = X`s`1 else X`d` = X`s`2 + 1 

有时使用预定义的宏 cinc 更加方便:

cinc W`d`, W`s1`, `cond`  // csinc  W`d`, W`s`1, W`s`1, invert(`cond`) 
cinc X`d`, X`s`1, `cond`  // csinc  X`d`, X`s`1, X`s`1, invert(`cond`) 

也就是说,如果条件为真,cinc 会递增并将源复制到目标寄存器;否则,它仅复制源而不进行递增。当然,如果你只是想有条件地递增某个特定寄存器,源和目标寄存器可以相同。请注意,cinc 宏的条件与 csinc 指令的条件相反。

接下来的两个条件指令是 csinv 和 csneg,它们会有条件地对值进行取反或求负:

csinv W`d`, W`s`1, W`s`2, `cond`  // if(`cond)` then W`d` = W`s`1  else W`d` = not W`s`2 
csinv X`d`, X`s`1, X`s`2, `cond`  // if(`cond)` then X`d` = X`s`1 else X`d` = not X`s`2 
csneg W`d`, W`s`1, W`s`2, `cond`  // if(`cond)` then W`d` = W`s`1 else W`d` = -W`s`2 
csneg X`d`, X`s`1, X`s`2, `cond`  // if(`cond)` then X`d` = X`s`1 else X`d` = -X`s`2 

还有 cinv 和 cneg 宏,它们只使用一个源操作数(类似于 cinc)。cset 和 csetm 宏是 csinc 和 cinv 的变体:

cset  Wd, `cond`  // if(`cond`) then W`d` = 1 else W`d` = 0 
cset  X`d`, `cond`  // if(`cond`) then X`d` = 1 else X`d` = 0 
csetm W`d`, `cond`  // if(`cond`) then W`d` = -1 else W`d` = 0 
csetm X`d`, `cond`  // if(`cond`) then X`d` = -1 else X`d` = 0 

cset 宏等同于将 WZR 或 XZR 作为源操作数的 cinc,而 csetm 宏等同于将 WZR 或 XZR 作为源操作数的 cinv。这些宏用于根据条件码将寄存器设置为布尔值(真/–1 或假/0)。

最后,ARM 还支持两条条件比较指令,ccmp 和 ccmn(条件比较负数),每条指令都有几种形式:

ccmp  W`d`, W`s`, #`nzcv4`, `cond` 
ccmp  X`d`, X`s`, #`nzcv4`, `cond` 
ccmp  W`d`, #imm5, #`nzcv4`, `cond` 
ccmp  X`d`, #imm5, #`nzcv4`, `cond` 
ccmn  W`d`, W`s`, #`nzcv4`, `cond` 
ccmn  X`d`, X`s`, #`nzcv4`, `cond` 
ccmn  W`d`, #imm5, #`nzcv4`, `cond` 
ccmp  X`d`, #imm5, #`nzcv4`, `cond` 

而 ccmp 通过将第二个操作数从第一个操作数中减去来进行比较,ccmn 则通过将第二个操作数加到第一个操作数上来进行比较。这些指令会测试提供的条件(cond)。如果条件为假,这些指令会将 4 位立即数 #nzcv4 直接复制到条件码中(第 3 位到 N,第 2 位到 Z,第 1 位到 C,第 0 位到 V)。

如果 cond 指定的条件为真,这些指令会将目标寄存器与源操作数(寄存器或 5 位无符号立即数)进行比较,并根据比较结果设置条件码位。正如你在本章后面将看到的,条件比较在评估复杂的布尔表达式时非常有用。

6.2 内存变量与寄存器

在开始将算术表达式转换为汇编语言语句之前,让我们先总结一下前五章关于变量的讨论。正如我多次指出的,ARM 基于加载/存储架构。ARM 配备了许多通用寄存器,你可以用它们来代替内存位置存储更常用的变量。通过仔细规划,你应该能够将大多数常用的变量保存在寄存器中。

考虑以下 C/C++ 语句及其转换为 ARM 汇编语言的代码:

x = y * z;

// Conversion to ARM assembly if x, y, and z are 32-bit
// memory variables in the .data section:

lea  x0, y     // Remember, lea expands to two instructions.
ldr  w0, [x0]
lea  x1, z
ldr  w1, [x1]
mul  w0, w0, w1
lea  x1, x
str  w0, [x1]

如果你将 x、y 和 z 分别保存在寄存器 W19、W20 和 W21 中,那么将该表达式转换为汇编语言的代码如下:

mul x19, x20, x21

这仅为前述转换大小的十分之一,且速度更快。

在像 ARM 这样的 RISC CPU 中,保持变量在寄存器中而不是内存中是一种更好的做法。作为汇编语言程序员,你的工作是仔细选择保存在寄存器中的变量和必须保存在内存中的(使用频率较低的)值。你可以通过计算在执行过程中访问变量的次数来做到这一点,将访问频率最高的变量保存在寄存器中,将访问频率最低的变量保存在内存中。

6.2.1 易失性寄存器与非易失性寄存器的使用

如果你在汇编代码中遵循 ARM ABI,你还必须注意过程中的易失性寄存器和非易失性寄存器之间的差异。使用非易失性寄存器是有成本的:如果你修改了非易失性寄存器的值,你必须在过程内保存寄存器的原始值。这通常涉及在过程的激活记录中分配存储空间,在进入过程时存储非易失性寄存器的值,并在返回之前恢复该寄存器的值。

使用易变寄存器意味着你可以避免为保存它们所需的开销和存储空间。然而,如果你调用了其他过程,而这些过程没有显式保存易变寄存器的内容,那么易变寄存器的内容可能会受到干扰。因为是调用者负责在其他函数调用之间保存任何易变寄存器的内容,所以如果你在过程内调用其他函数,你最好使用非易变寄存器(假设有可用的寄存器)。

当然,这假设你所调用的函数遵循 ARM ABI 约定。例如,如果你调用的汇编语言函数保留了它们修改的所有寄存器值,即使 ARM ABI 将它们视为易变寄存器,你也不需要担心保存这些寄存器。

6.2.2 全局变量与局部变量

如果你必须使用内存——因为你没有足够的寄存器资源,或者因为你需要操作一个无法适应寄存器的大数据结构——你可以将必须保留的变量放入内存中。你可以将它们放在全局静态数据段(如 .data、.bss 等)中,或者放在你为当前过程创建的激活记录中。

当你学习高级语言编程时,可能被教导要避免在程序中使用全局变量。这个建议在 ARM 汇编语言中更为适用,尤其是在 macOS 上编程时。在 macOS 下,正如你多次看到的那样,访问全局数据比访问激活记录中的局部数据更昂贵。要从全局(.data)内存中获取一个 32 位变量,需要使用以下代码:

lea x0, globalVariable // Remember, this is two instructions.
ldr w0, [x0]

从局部变量中获取数据只需一条指令(前提是该变量在激活记录中的偏移量相对较小):

ldr w0, [fp, #localVariable]

这意味着,访问局部变量所需的指令数量是访问全局变量的三分之一。

当然,如果你在 Linux 上运行,并且不需要你的汇编代码也能在 macOS 上运行,你也可以通过使用单条指令和 PC 相对寻址模式来访问全局变量:

ldr w0, globalVariable

只需记住,数据必须位于该指令的±1MB 范围内。当编写较大应用程序时,超出这个限制是非常容易的。

局部变量也不是没有限制。通常,激活记录的存储限制约为 ±256 字节,如果你能使用缩放间接加偏移寻址模式来处理半字、字和双字变量,存储空间会稍微多一点。幸运的是,在单个过程中,你很少会超过这个字节数的标量(非数组/非结构体)变量。如果你确实需要更多空间,你必须计算变量在激活记录中的有效地址,这将需要和访问全局变量一样多的指令。

6.2.3 轻松访问全局变量

为了使访问.data.bss节中的全局变量和访问激活记录中的局部变量一样容易,你可以创建一个静态激活记录。局部变量容易访问,因为你使用间接加偏移量(或缩放的间接加偏移量)寻址模式,从 FP 寄存器索引。如果 FP 指向静态数据节的等效位置呢?尽管 ARM 没有提供 SB(静态基址)寄存器,但没有任何限制阻止你创建自己的寄存器:

#define SB X28

在这个示例中,我选择使用 X28,因为它是 ARM ABI 中的一个非易失性寄存器,且位于 FP(X29)寄存器下方。

清单 6-1 演示了如何使用 SB 寄存器(X28)高效访问全局变量。

// Listing6-1.S
//
// Demonstrate using X28 as a "static base"
// register to conveniently access global
// variables.

        #include    "aoaa.inc"

#define sb X28          // Use X28 for SB register.

// Declaration of global variables:

        struct  globals_t
        word    g1
        dword   g2
        hword   g3
        byte    g4,128
        ends    globals_t

        .data

        globals_t globals       // Global variables go here.

        .text
        .pool
ttlStr: wastr  "Listing 6-1"

        proc    getTitle, public
        lea     x0, ttlStr
        ret
        endp    getTitle

        proc    asmMain, public

        locals  am
        dword   saveSB             // Save X28 here.
        byte    stackSpace, 64     // Generic stack space
        endl    am

        enter   am.size            // Reserve space for locals.
        str     sb, [fp, #saveSB]  // Preserve SB register.
        lea     sb, globals        // Initialize with address.

        mov     w0, #55            // Just demonstrate the
        str     w0, [sb, #g1]      // use of the static
        add     x0, x0, #44        // base record in the
        str     x0, [sb, #g2]      // .data section.
 and     w0, w0, #0xff
        strh    w0, [sb, #g3]

        ldr     sb, [fp, #saveSB]  // Restore SB register.
        leave                      // Return to caller.
        endp    asmMain

请记住,[sb, #offset]寻址模式的范围限制为±256 字节(或者在使用缩放的间接加偏移量模式时,最多为 1KB),因此最好将非标量(复合)变量放在静态记录之外。

如写出,清单 6-1 中的 globals 记录仅提供 256 字节的存储空间(因为所有结构字段偏移量都是正数或 0)。以下声明将偏移量从-256 开始,在静态记录中提供额外的 256 字节存储空间:

struct  globals_t, -256
word    g1
dword   g2
hword   g3
byte    g4,128
ends    globals_t

然而,如果你这样做,你必须适当地调整加载到 SB 中的值,如下所示:

lea sb, globals+256  // Initialize with address.

这样 SB 将指向 globals_t 结构中的正确位置。

6.3 算术表达式

初次接触汇编语言的初学者可能会感到最大的震惊是缺乏熟悉的算术表达式。大多数高级语言中的算术表达式与代数表达式相似。例如,在 C 语言中,你可以写出类似以下的代数式:

x = y * z;

在汇编语言中,如果这些变量位于内存位置(假设它们是局部变量),你需要使用多个语句来完成相同的任务:

ldr w0, [fp, #y]
ldr w1, [fp, #z]
mul w0, w0, w1
str w0, [fp, #x]

// If you can keep x, y, and z in registers:

mul x0, x1, x2  // Assume x = X0, y = X1, and z = X2.

显然,高级语言版本更容易输入、阅读和理解。尽管涉及很多打字,转换算术表达式为汇编语言并不困难。通过分步解决问题,就像你手动解决问题一样,你可以轻松地将任何算术表达式分解为等效的汇编语言语句序列。

6.3.1 简单赋值

转换为汇编语言最简单的表达式是简单赋值,它将一个值复制到一个变量中,具有两种形式:

`variable` = `constant`

或者

`var1` = `var2`

如果你的变量位于寄存器中,将这些语句转换为汇编语言是很简单的:

mov `variable`, #`constant`  // Assumption: constant fits in 16 bits.
mov `var1`, `var2`

该 mov 指令将源常量或寄存器复制到目标寄存器中。

如果常量太大,你将不得不使用 movk 序列(见第 2.20.2 节,“movk”,在第 112 页)或 ldr 的常量形式:

ldr `register`, =`constant`

如果源变量在内存中,你必须使用 ldr 指令从内存中获取数据,如以下示例所示:

ldr `register`, [fp, #`offset`]  // Assuming a local variable
ldr `register`, [sb, #`offset`]  // Assuming variable is in static record

lea `reg`64, `GlobalVariable`     // Global variable in arbitrary memory
ldr `register`, [`reg`64]

如果目标是内存变量,必须先将源变量或常量加载到寄存器中(如果它尚未在寄存器中),然后使用 str 指令将值存储到内存变量中:

str `register`, [fp, #`offset`]
str `register`, [sb, #`offset`]

lea `reg`64, `GlobalVariable`
str `register`, [`reg`64]

显然,最有效的代码是在两个变量都在寄存器中,或者目标是寄存器且源值是一个小常量的情况下,在这种情况下只需一条 mov 指令即可。

6.3.2 简单表达式

下一层复杂度是 简单表达式,其形式为

`var1` = `term1 op term2`;

其中,var1 是一个变量,term1 和 term2 是变量或常量,op 是一个算术操作符(加法、减法、乘法等)。大多数表达式采取这种形式。因此,ARM 架构专门针对这种类型的表达式进行了优化,这一点也不足为奇。

假设 var1、term1 和 term2 都在寄存器中,典型的转换形式如下:

`op`  `var1`, `term1`, `term2`

其中,op 是与指定操作对应的助记符(例如,+ 是 add,– 是 sub,等等)。

请注意,简单表达式

`var1` = `const1` op `const2`;

通过编译时表达式和单条 mov 指令可以轻松处理。例如,计算

`var1` = 5 + 3;

你将使用单条指令:

mov `var1`, #5 + 3

如果 term2 是一个(足够小的)常量,通常可以使用如下形式的指令:

`op`  `var1`, `term1`, #`constant`

然而,存在一些例外情况。某些指令,如 mul 和 udiv/sdiv,不允许立即数操作数。在这种情况下,你需要使用两条指令

`mov someReg`, #`constant`
`op`  `var1`, `term1`, `someReg`

在这里,someReg 是一个可用的临时寄存器。

如果 term1 是常量,而 term2 是寄存器,则对于交换律操作,你只需在指令中交换两个源操作数即可。例如

x0 = 25 + x1;

变成这样:

add x0, x1, #25

对于非交换的操作(如减法和除法),这种方案不起作用。你可能需要在操作之前将常量加载到寄存器中。

当然,如果常量太大(通常是算术指令的 12 位),你将需要先使用 mov、movk 或 ldr 指令将该常量加载到寄存器中。

如果你的操作数是内存变量而不是寄存器(或常量),则需要使用 ldr 指令将内存变量加载到寄存器中,然后再进行操作。同样,如果目标变量在内存中,操作完成后需要使用 str 指令将值存储到内存变量中。例如

x = y + z;  // x, y, and z are all 32-bit memory variables.

变成这样:

ldr w0, [fp, #y]   // Assuming y is a local variable
ldr w1, [sb, #z]   // Assuming z is in the static base record
add w2, w0, w1
lea x3, globalVar  // Assuming globalVar is a global variable
str w2, [x3]       // in the .data section

这里是一些常见简单表达式的示例(假设 x、y 和 z 分别在 W0、W1 和 W2 寄存器中):

// x = y + z;  // Signed or unsigned

          add w0, w1, w2

// x = y - z;  // Signed or unsigned

          sub w0, w1, w2

// x = y * z;  // Signed or unsigned

          mul w0, w1, w2

// x = y / z;  // Unsigned div

          udiv w0, w1, w2

// x = y / z;  // Signed div

          sdiv w0, w1, w2

// x = y % z;  // Unsigned remainder

          udiv  x0, x1, x2
          msub  x0, x0, x2, x1

// x = y % z;  // Signed remainder

          sdiv  x0, x1, x2
          msub  x0, x0, x2, x1

如果任一操作数是内存变量,首先必须使用 ldr 指令将其加载到寄存器中。如果操作数是常量,则请按照上一节的指导进行处理。

6.3.3 复杂表达式

一个 复杂表达式 是指包含两个以上项和一个运算符的任何算术表达式。这样的表达式通常出现在用高级语言编写的程序中。复杂表达式可能包含括号来覆盖运算符优先级、函数调用、数组访问等。本节概述了转换此类表达式的规则。

容易转换为汇编语言的复杂表达式通常包含三个项和两个运算符。以下是一个例子:

w = w - y - z;

显然,这个语句的直接汇编语言转换需要两个子指令。然而,即使是像这样的简单表达式,转换也不是小事。你可以通过两种方式将上述语句转换为汇编语言(假设 w 在 W0 中,y 在 W1 中,z 在 W2 中):

sub w0, w0, w1
sub w0, w0, w2

或者

sub w3, w1, w2
sub w0, w0, w3

两种方法可能会产生不同的结果,第一个转换大体上遵循 C 语言语义。问题在于结合性。前面的第二个序列计算 w = w - (y - z),这与 w = (w - y) - z 不同。子表达式周围括号的位置会影响结果。

优先级,即操作发生的顺序,是另一个问题。考虑以下表达式:

x = w * y + z;

再次,你可以通过两种方式来计算这个表达式:

x = (w * y) + z;

或者

x = w * (y + z);

到现在为止,你可能会觉得这个解释有些疯狂——每个人都知道正确的方式是使用前一种形式来计算这些表达式。然而,这并不总是正确的。例如,APL 编程语言仅从右到左计算表达式,并且不赋予任何运算符优先级。所谓的“正确”方法完全取决于你如何定义算术系统中的优先级。

考虑以下表达式:

x `op1` y `op2` z

如果 op1 的优先级高于 op2,则此表达式计算为 (x op1 y) op2 z. 否则,如果 op2 的优先级高于 op1,则表达式计算为 x op1 (y op2 z)。根据涉及的运算符和操作数,这两个计算可能会产生不同的结果。

大多数高级语言(HLL)使用一组固定的优先级规则来描述包含两个或更多不同运算符的表达式的计算顺序。这些编程语言通常在加法和减法之前计算乘法和除法。那些支持指数运算的语言(例如 FORTRAN 和 BASIC)通常在乘法和除法之前计算指数运算。这些规则是直观的,因为大多数人在上高中之前就学到了它们。

在将表达式转换为汇编语言时,必须确保首先计算具有最高优先级的子表达式。以下示例演示了这一技术(假设乘法的优先级高于加法):

// w = x + y * z;  // Assume w = W0, x = W1, y = W2, and z = W3.

mul w4, w2, w3  // W4 = W2 * W3
add w0, w1, w4  // W0 = W1 + (W2 * W3)

如果在一个表达式中出现的两个操作符具有相同的优先级,则使用结合性规则来确定求值顺序。大多数操作符是左结合的,这意味着它们从左到右求值。加法、减法、乘法和除法都是左结合的。右结合的操作符从右到左求值。FORTRAN 中的幂运算符就是一个右结合操作符的好例子。例如:

2**2**3

等于

2**(2**3)

不是

(2**2)**3

优先级和结合性规则决定了求值的顺序。间接地,这些规则告诉你在表达式中该把括号放在哪里,以确定求值顺序。当然,你始终可以使用括号来覆盖默认的优先级和结合性。然而,最重要的一点是,你的汇编代码必须在其他操作之前完成某些操作,以正确计算给定表达式的值。以下示例演示了这一原则:

// w = x - y - z  // Assume w = W0, x = W1, y = W2, and z = W3.

sub  w0, w1, w2  // Evaluate from left to right.
sub  w0, w0, w3  // W0 = (x - y) - z

// w = x + y * z

mul  w0, w2, w3  // Must compute y * z first.
add  w0, w0, w1  // W0 = (W2 * W3) + W1  (commutative)

或者,更好的是

madd w0, w2, w3, w1  // W0 = (W2 * W3) + W1

// w = x / y - z

sdiv w0, w1, w2  // Division has highest precedence.
sub  w0, w0, w3  // W0 = (W1 / W2) - W3

// w = x * y * z

mul  w0, w1, w2  // Commutative, so order doesn't matter.
mul  w0, w0, w3

结合性规则有一个例外:如果一个表达式涉及乘法和除法,通常最好先进行乘法。例如,给定如下形式的表达式

w = x / y * z;  // Note: this is (x / y) * z, not x / (y * z).

通常最好先计算 x * z,然后将结果除以 y,而不是先将 x 除以 y,再将商乘以 z。先进行乘法运算能提高计算的精确度。记住,(整数)除法通常会产生不精确的结果。例如,如果你计算 5 / 2,你将得到 2,而不是 2.5。计算(5 / 2) × 3 得到 6。然而,计算(5 × 3) / 2 得到 7,这比实际商 7.5 更接近。

因此,如果你遇到如下形式的表达式

w = x / y * z;  // Assume w = W0, x = W1, y = W2, and z = W3.

你通常可以将其转换为以下汇编代码:

mul  w0, w1, w3   // w = x * z
sdiv w0, w0, w2   // w = (x * z) / y

如果乘法可能会导致溢出,首先进行除法运算可能会更好。

如果你编码的算法依赖于除法运算的截断效果,那么不能使用这个技巧来优化算法。这个故事的教训是,你应该始终确保完全理解你要转换成汇编语言的任何表达式。如果语义要求你必须首先执行除法操作,那就照做。

请考虑以下语句:

w = x - y * z;  // Assume w = W0, x = W1, y = W2, and z = W3.

因为减法不是交换律的,你不能先计算 y * x,然后从结果中减去 x。你不能使用简单的乘法和减法序列,而是必须使用一个临时寄存器来保存乘积。例如,以下两个指令使用 W4 作为临时寄存器:

mul  w4, w2, w3  // temp = y * z
sub  w0, w1, w4  // w = x - (y * z)

随着表达式复杂度的增加,临时变量的需求也会增加。请考虑以下 C 语句:

w = (a + bb) * (y + z);

按照常规的代数计算规则,首先计算括号内的子表达式(即优先级最高的两个子表达式),并将它们的值暂时保留。当你计算出两个子表达式的值后,就可以计算它们的乘积。处理这种复杂表达式的一种方法是将其简化为一系列简单表达式,其结果存储在临时变量中。例如,你可以将上面的单个表达式转换为以下的表达式序列:

temp1 = a + bb;
temp2 = y + z;
w = temp1 * temp2;

由于将简单表达式转换为汇编语言很容易,现在转换前面的复杂表达式为汇编语言变得十分简单,如下面的代码所示:

// Assume w = W0, y = W1, z = W2, a = W3, and bb = W4.

add  w5, w3, w4  // temp1 (W5) = a + bb
add  w6, w1, w2  // temp2 (W6) = y + z
mul  w0, w5, w6  // w = temp1 * temp2

这是另一个复杂算术转换的例子:

x = (y + z) * (a - bb) / 10;

你可以将其转换为一组四个简单表达式:

temp1 = (y + z)
temp2 = (a - bb)
temp1 = temp1 * temp2
x = temp1 / 10

你可以将这四个表达式转换成以下的汇编语言语句:

// Assume x = W0, y = W1, z = W2, a = W3, and bb = W4.

add  w5, w1, w2  // temp1 (W5) = y + z
sub  w6, w3, w4  // temp2 (W6) = a - bb
mul  w5, w5, w6  // temp1 = temp1 * temp2
mov  w6, #10     // Need a temp to hold constant 10.
sdiv w0, w5, w6  // x = temp1 / 10

最重要的是,确保为了效率,临时值保存在寄存器中。只有当寄存器用尽时,才使用内存位置来保存临时值。

简而言之,正如你所看到的,将复杂的表达式转换为汇编语言与手动求解表达式有所不同。你并不是在每个计算阶段计算结果,而是编写汇编代码来计算结果。

6.3.4 交换律运算符

如果 op 表示一个运算符,则如果以下关系始终成立,则该运算符是交换律的:

(A `op` B) = (B `op` A)

正如你在上一节学到的,交换律运算符容易转换,因为其操作数的顺序不重要,这使得你可以重排计算,通常能使计算更加简便或高效。重排计算往往能减少临时变量的使用。每当你在表达式中遇到交换律运算符时,检查是否可以使用更好的顺序来提高代码的大小或速度。

表 6-3 列出了通常在高级语言中找到的交换律运算符。

表 6-3:交换律二元(双操作数)运算符

Pascal C/C++及类似语言 描述
+ + 加法
* * 乘法
and && 或 & 逻辑与按位与
or || 或 | 逻辑或按位或
xor ^ 逻辑或按位异或
= == 等于
<> != 不等于

表 6-4 列出了许多非交换律运算符。

表 6-4:非交换二元运算符

Pascal C/C++及类似语言 描述
- - 减法
/ 或 div / 除法
mod % 余数(模)
< < 小于
<= <= 小于或等于
> > 大于
>= >= 大于或等于

如果遇到其他类型的运算符,请检查相关的高级语言定义,确定它们是交换律运算符还是非交换律运算符,并确定它们的优先级和结合性。

6.4 逻辑表达式

考虑以下来自 C/C++ 程序的逻辑(布尔)表达式:

bb = ((x == y) && (a <= c)) || ((z - a) != 5);

在这里,bb 是一个布尔变量,其他变量都是整数。

尽管只需要一个比特来表示布尔值,大多数汇编语言程序员会分配一个完整的字节,甚至一个字(word)来表示布尔变量。大多数程序员(实际上,一些编程语言如 C)选择 0 表示 (false),而其他任何值表示 (true)。有些人喜欢用 1 和 0 来分别表示真和假,不允许其他值。还有一些人选择使用全 1 位(0xFFFF_FFFF_FFFF_FFFF、0xFFFF_FFFF、0xFFFF 或 0xFF)表示真,0 表示假。你也可以使用正值表示真,负值表示假。

所有这些机制都有各自的优缺点。仅使用 0 和 1 来表示假(false)和真(true)有两个很大的优点。首先,cset 指令可以产生这种结果,因此该方案与该指令兼容。其次,ARM 逻辑指令(and, orr, eor 和 mvn)正如你所期望的那样,操作这些值。如果你有两个布尔变量 a 和 bb,以下指令将对这两个变量执行基本的逻辑操作:

// d = a AND bb;  // Assume d = W0, a = W1, and bb = W2.

and w0, w1, w2

// d = a || bb;

orr w0, w1, w2

// d = a XOR bb;

eor w0, w1, w2

// bb = NOT a;
//
// (NOT 0) does not equal 1.
// The AND instruction corrects this problem.

mvn w2, w1
and w2, w2, #1

// Here's an alternative solution (for NOT) using EOR:

eor w2, w1, #1  // Inverts bit 0

mvn 指令无法正确计算逻辑取反操作。0 的按位 NOT 结果是 0xFF(假设是字节值),而 1 的按位 NOT 结果是 0FEh。两者的结果都不是 0 或 1。然而,将结果与 1 做与运算(AND)会得到正确的结果。你可以通过使用 eor 指令(如最后给出的 eor 示例所示)更高效地实现取反操作,因为它只影响最低有效位(LO 位)。

使用 0 表示假(false),而其他任何值表示真(true)有许多微妙的优点。真假测试通常隐式地包含在任何逻辑指令的执行中。然而,这种机制有一个主要的缺点:你不能总是使用 ARM 的 and、orr、eor 和 mvn 指令来实现同名的布尔运算。考虑以下两个值 0x55 和 0xAA。它们都是非零值,因此它们都表示真(true)。然而,如果你使用 ARM 的 and 指令对 0x55 和 0xAA 进行逻辑与运算,结果将是 0。真与真应该得到真,而不是假(false)。尽管你可以处理这种情况,但通常需要额外的一些指令,且计算布尔运算时效率较低。

使用非零值表示真,0 表示假是 算术逻辑系统。使用 0 和 1 两个不同值来表示假和真的是一个 布尔逻辑系统,简称布尔系统。你可以根据需要选择使用这两种系统。考虑这个布尔表达式:

bb = ((x == y) and (a <= d)) || ((z - a) != 5);

结果的简单表达式可能如下所示:

// Assume bb = W0, x = W1, y = W2, a = W3, d = W4, and z = W5.

cmp  w1, w2
cset w6, eq      // temp1 (W6) = x == y

cmp  w3, w4
cset w7, le      // temp2 (W7) = a <= d
and  w6, w6, w7  // temp1 = (x == y) && (a <= d)

sub  w7, w5, w3  // temp2 = z - a
cmp  w7, #5
cset w7, ne      // temp2 = (z - a) != 5

orr  w0, w6, w7  // W0 = temp1 || temp2

在处理布尔表达式时,不要忘记你可以通过代数变换简化代码来优化它。在第七章中,你还将看到如何使用控制流来计算布尔结果,这比本节示例中使用的方法可能更高效。

6.5 条件比较和布尔表达式

条件比较指令 ccmp 在汇编语言中编码复杂的布尔表达式时非常有用。考虑以下布尔表达式:

bb = (x == y) && (a <= d)

使用上一节的逻辑,你可以将其转换为以下汇编语言代码:

// Assume bb = W0, x = W1, y = W2, a = W3, and d = W4.

cmp  w1, w2
cset w5, eq   // temp1 (W5) = x == y

cmp  w3, w4
cset w6, le   // temp2 (W6) = a <= d
and  w0, w5, w6   // bb = (x == y) && (a <= d)

通过使用条件比较指令,你可以将临时值保存在条件代码标志中,从而缩短代码长度:

cmp  w1, w2
ccmp w3, w4, #0, eq
cset w0, le

第一个 cmp 指令如果 x 等于 y,会设置 Z 标志。如果该条件为假,则整个逻辑表达式必须返回假。如果条件为真,这段代码必须测试 a 是否小于或等于 d。

假设 x 不等于 y,在第一次 cmp 指令之后,Z 标志将被清除。在这种情况下,ccmp 指令不会将 W3(a)与 W4(d)进行比较,而是将标志加载为 0b0000(因为当条件 eq 为真时,ccmp 指令只比较前两个操作数;此时它不为真)。由于所有标志都被清除(即 N == V 且 Z != 1),cset 指令的 le 条件为假;因此,该指令将把 0 存入 W0(bb),这正是你想要的。

另一方面,如果 x 等于 y,则 ccmp 指令的 eq 条件为真,并将比较 W3(a)与 W4(d)的值。如果 a 小于或等于 d,N、V 和 Z 标志将被设置,以使得 cset 指令将 1 移动到 W0 中。否则,cset 将把 0 移动到 W0,这同样是你想要的。这个只有三条指令的序列完成了早期五条指令的工作,是一个巨大的胜利。

6.5.1 使用 ccmp 实现与运算

考虑这个 C/C++逻辑表达式:

(a cc1 bb) && (c cc2 d)

一般来说,要将包含逻辑与操作符(&&)的表达式转换为 ARM 汇编,使用条件比较指令,你需要按照以下五个步骤进行:

1.  比较与运算符左侧的操作数 cc1(见表 6-5)。

2.  在第一次比较之后,立即执行 ccmp 指令,提供 cc1 作为条件字段。

3.  从对面一列的表 6-5 中选择相应的#nzcv 编码,以匹配 cc2。完整的 ccmp 指令应为:

ccmp c, d, #nzcvop, cc1

4.  序列中的最后一条指令应当测试 cc2,如以下示例所示:

cset x0, cc2
  1. 如果 cc1 失败,ccmp 指令将会把标志设置为 #nzcvop 的值,而不是比较 c 和 d。由于你希望布尔表达式在这种情况下为假,选择一个与 cc2 相反的 #nzcvop 值,以使得接下来的测试(例如 cset)返回假结果。如果在执行 ccmp 指令时 cc1 为真,ccmp 将会比较 c 和 d 并设置标志。

表 6-5:条件运算符、反义和 NZCV 设置

C/C++ 运算符 #nzcv 反义 #nzcvop
== eq 0b0100 ne 0b0000
!= ne 0b0000 eq 0b0100
> (无符号) hi 0b0010 ls 0b0100
>= (无符号) hs 0b0110 lo 0b0000
< (无符号) lo 0b0000 hs 0b0110
<= (无符号) ls 0b0100 hi 0b0010
> (有符号) gt 0b0000 le 0b0101
>= (有符号) ge 0b0100 lt 0b0001
< (有符号) lt 0b0001 ge 0b0100
<= (有符号) le 0b0101 gt 0b0000
同 hs cs 0b0010 cc 0b0000
同 lo cc 0b0000 cs 0b0010
N/A vs 0b0001 vc 0b0000
N/A vc 0b0000 vs 0b0001
N/A mi 0b1000 pl 0b0000
N/A pl 0b0000 mi 0b1000

因为保持第三个 ccmp 操作数的标志设置在脑海中是困难且容易出错的,aoaa.inc 包含了多个定义,使得选择这些值变得更容易,同时还包含了一些用于反义条件的定义。表 6-6 列出了这些定义及其值。

表 6-6:NZCV 常量定义

条件 定义
eq cceq 0b0100 (nZcv)
ne ccne 0b0000 (nzcv)
hi cchi 0b0010 (nzCv)
hs cchs 0b0110 (nZCv)
lo cclo 0b0000 (nzcv)
ls ccls 0b0100 (nZcv)
gt ccgt 0b0000 (nzcv)
ge ccge 0b0100 (nZcv)
lt cclt 0b0001 (nzcV)
le ccle 0b0101 (nZcV)
cs cccs 0b0010 (nzCv)
cc cccc 0b0000 (nzcv)
vs ccvs 0b0001 (nzcV)
vc ccvc 0b0000 (nzcV)
mi ccmi 0b1000 (Nzcv)
pl ccpl 0b0000 (nzcv)

表 6-7 列出了常见的反义(对立条件)。

表 6-7:NZCV 反义常量

条件 定义 相同的
非 hi ccnhi ccls
非 hs ccnhs cclo
非 lo ccnlo cchs
非 ls ccnls cchi
非 gt ccngt ccle
非 ge ccnge cclt
非 lt ccnlt ccge
非 le ccnle ccgt

使用这些符号代替立即数 ccmp 指令操作数,可以使你的代码更易于阅读和理解。

有时,在条件指令中指定相反条件可能会造成混淆。比如,容易认为“小于”的相反是“大于”,而实际上应该是“大于或等于”。为减少这种混淆,aoaa.inc包含文件还提供了几个相反条件的定义,如表 6-8 所列。

表 6-8:相反条件定义

Condition 相反定义
lo nlo(与 hs 相同)
ls nls(与 hi 相同)
hi nhi(与 ls 相同)
hs nhs(与 lo 相同)
gt ngt(与 le 相同)
ge nge(与 lt 相同)
lt nlt(与 ge 相同)
le nle(与 gt 相同)

通过使用aoaa.inc定义,你可以让你的代码更容易阅读和理解。

6.5.2 使用 ccmp 实现析取

条件比较也可以用来模拟析取(逻辑或)。考虑以下表达式:

bb = (x == y) || (a <= d)

这是该表达式转换为汇编语言的过程:

cmp  w1, w2
ccmp w3, w4, #0b0100, ne  // 0b0100 is .Z.. or use #cceq
cset w0, le               // or #ccle

请注意,条件比较指令如何测试不等于条件。如果 x 等于 y,则不需要进行此比较。在这种情况下,ccmp 指令会将 0b0100 加载到条件码中,这将使 Z 设为 1 并清除所有其他标志。当 cset 指令测试小于或等于时,存在相等条件(Z = 1),将 W0(bb)设为 1。比较 a 和 d 在 bb 值的计算中没有任何作用。

如果 x 不等于 y,当程序执行 ccmp 指令时,ne 条件将会存在。因此,ccmp 将比较 a 和 d,并根据该比较设置条件码位。此时,cset 指令将根据 a 和 d 的比较设置 bb 的值。

以下算法描述了如何通过条件比较将包含析取(disjunction)的表达式转换为 ARM 汇编语言:

(a cc1 bb) || (c cc2 d)

以下是转换过程中需要遵循的四个步骤:

  1. 比较析取操作符左侧的操作数(操作符为 cc1)。

  2. 在第一次 cmp 指令之后,立即执行 ccmp 指令,提供 cc1 的相反条件作为条件字段(返回表 6-5 查找相反条件)。

  3. 选择与 cc2 匹配的#nzcv 编码,参见表 6-5 中的常规列。完整的 ccmp 指令应如下所示:

ccmp c, d, #nzcv, opposite(cc1)
  1. 序列中的最后一条指令应测试 cc2。例如:
cset x0, cc2

如果 cc1 成功,ccmp 指令将会将标志设置为#nzcvop 值,并且不再比较 c 与 d,因为你已经为 ccmp 条件选择了 cc1 的相反条件。由于你希望布尔表达式在这种情况下为真,因此选择一个与 cc2 相同的#nzcvop 值,使得接下来的测试(例如 cset)产生真实的结果。如果执行 ccmp 指令时 cc1 为假,ccmp 将比较 c 和 d,并根据接下来的测试设置适当的标志。

6.5.3 处理复杂布尔表达式

你可以通过在序列中添加额外的 ccmp 指令来扩展布尔表达式。只需记住,至少在 C/C++中,连接的优先级高于析取,所以当表达式中包含两者运算符时,你必须调整评估顺序,首先处理连接。

还需要注意的是,ccmp 方案使用完整的布尔评估(意味着它会评估布尔表达式的每个子项),而 C++编程语言使用短路布尔评估(这可能不会计算所有的子项)。第七章对这两种形式进行了更详细的介绍,但目前只需知道这两种形式可能会产生不同的结果。

6.6 机器和算术习惯用法

习惯用法是指特殊的个性特征(独特性)。一些算术操作和 ARM 指令具有你可以利用的特性,在编写汇编语言代码时可以发挥作用。有些人称使用机器和算术习惯用法为技巧编程,而你应该始终避免在编写良好程序时使用这些技巧。虽然仅仅为了使用技巧而使用技巧是不明智的,但许多机器和算术习惯用法是广为人知的,并且在汇编语言程序中很常见。本节概述了你最常见的习惯用法。

6.6.1 无需 mul 的乘法

当乘以常数时,有时你可以通过使用移位、加法和减法来代替乘法指令,从而编写等效代码。尽管使用 mul 指令与其他算术指令在性能上的差异很小,但某些涉及移位的寻址模式变体可以避免额外的乘法指令。

记住,lsl 指令计算的结果与将指定操作数乘以 2 的结果相同。将操作数左移两位将其乘以 4,左移三位将其乘以 8。一般来说,将操作数左移n位将其乘以 2^n。你可以通过一系列的移位和加法或移位和减法来将任何值乘以常数。例如,要将 W0 寄存器乘以 10,你只需先将其乘以 8,然后再加上原始值的 2 倍。即,10 × W0 = 8 × W0 + 2 × W0。你可以使用以下代码来实现这一点:

lsl w0, w0, #1          // W0 = W0 * 2
add w0, w0, w0, lsl #2  // W0 = (W0 * 2) + (W0 * 8)

第一条指令将 W0 乘以 2,因此当第二条指令将 W0 左移 2 位时,实际上是将原始 W0 值左移 3 位。

从指令时序来看,你会发现乘法指令与 lsl 或 add 指令的执行速度相同,因此第二个序列并不更快。然而,如果你必须将常数 10 加载到寄存器中以进行乘以 10 的操作,这个序列并不比乘法指令慢。如果你已经在其他计算中进行了移位操作,这个序列可能会更快。

你还可以通过结合移位和减法来执行乘法操作。考虑下面的乘以 7 的例子:

sub w0, w0, w0, lsl #3  // Actually computes W0 * (-7)
neg w0, w0              // Fix sign.

初学汇编语言的程序员常犯一个错误,即用 1 或 2 相加或相减,而不是使用 W0 × 1 或 W0 × 2。下面的代码并没有计算 W0 × 7:

lsl w0, w0, #3
sub w0, w0, #1

其实,这段代码计算的是(8 × W0) – 1,结果完全不同(除非 W0 = 1)。在使用移位、加法和减法来执行乘法操作时要小心这个陷阱。

操作数 2 寻址模式的变化,尤其是涉及 lsl 的那些变化,对于将移位操作与其他算术操作结合起来非常有用。例如,考虑下面这对指令:

lsl w0, w0, #3
add w1, w1, w0

你可以很容易地用一条指令替换这段代码:

add w1, w1, w0, lsl #3

因为像 ARM 这样的 RISC CPU 通常能够在单个 CPU 时钟周期内执行大多数指令,所以使用强度削减优化(例如,用移位和加法替代乘法)很少能够带来好处。一般来说,单个移位指令(用于乘以 2 的幂)可能比乘法指令(mul)效果更好;超出这个范围,除非你需要这些移位和加法来进行其他计算,否则通常不会提高速度。

6.6.2 没有 sdiv 或 udiv 的除法

正如 lsl 指令对于模拟乘以 2 的幂非常有用一样,lsr 和 asr 指令可以模拟除以 2 的幂。不幸的是,你不能轻松地通过移位、加法和减法来执行除以任意常数的操作。因此,这个技巧只在除以 2 的幂时有用。另外,别忘了,asr 指令是向负无穷大舍入的,而 sdiv 指令则是向 0 舍入的。

在 ARM64 CPU 上,除法指令的执行时间通常是其他指令的两倍。因此,如果你能够通过使用一个右移指令模拟除以 2 的幂,代码的执行速度将稍微提高。你还可以通过乘以倒数来进行除法。通常,乘法比除法要快,因为乘法指令比除法指令执行得更快。

在处理整数时,如果想要乘以倒数,你必须采取一些技巧。如果你想乘以 1/10,事先将 1/10 加载到 ARM 整数寄存器中是做不到的。将 1/10 乘以 10 进行运算,然后再用 10 除以结果以得到最终结果是行不通的。实际上,这会使性能更差,因为你现在需要执行一次乘法(乘以 10)和一次除法(除以 10)。但是,假设你将 1/10 乘以 65,536(即 6,554),执行乘法后再除以 65,536,看看下面这段代码,它实现了 W0 除以 10:

mov w1, #6554
mul w0, w0, w1
lsr w0, w0, #16  // Division by 65,536

这段代码将 W0 / 10 存放在 W0 寄存器中。要理解这如何工作,可以考虑使用 mul 指令将 W0 乘以 65,536(0x1_0000)时会发生什么。这会将 W0 的低 16 位移动到高 16 位,并将低 16 位设置为 0(乘以 0x1_0000 相当于左移 16 位)。将 W0 乘以 6,554(65,536 除以 10)后,W0 除以 10 的结果会放入 W0 寄存器的高 16 位。

乘以倒数仅在除以常数(如 10)时有效。虽然你可以通过多条指令强制执行将寄存器除以非恒定值的计算,但到那时 udiv/sdiv 指令显然会更快;而乘以倒数是否比除法更快仍然值得怀疑。

6.6.3 使用与运算实现模 N 计数器

要实现一个计数器变量,该变量从 0 计数到 2^n – 1 然后重置为 0,可以使用以下代码:

add w0, w0, #1
and w0, w0, #`nBits`

其中 nBits 是一个包含n个 1 位、右对齐的二进制值。例如,要创建一个从 0 计数到 15(2⁴ – 1)的计数器,你可以使用以下代码:

add w0, w0, #1
and w0, w0, #0b1111

6.6.4 避免不必要的复杂机器习语

你刚刚学习的机器习语在提高老旧的复杂指令集计算机(CISC)的性能时效果很好,这些计算机通常需要执行每条指令时消耗不同数量的 CPU 时钟周期。例如,在 x86 CPU 上,像除法这样的复杂指令可能需要超过 50 个时钟周期。RISC CPU,如 ARM,试图在一个时钟周期内执行指令。虽然 ARM 并不总是能做到这一点(例如 sdiv 和 udiv 稍慢一些),但所需的额外时间不足以让我们用其他长指令序列来替代这条指令。

使用机器习语会使你的代码更难以阅读和理解。如果使用机器习语没有明显的性能提升,最好使用更易于理解的代码。之后在你项目中工作的人(包括未来的你)会感谢你。

6.7 浮点和有限精度运算

在讨论 ARM CPU 如何实现浮点运算之前,首先描述浮点运算背后的数学理论以及你在使用过程中会遇到的问题是很有价值的。本节将介绍一个简化模型,来解释浮点运算以及为什么你不能将标准代数规则应用于涉及浮点运算的计算。

6.7.1 基础浮点术语

整数运算无法表示小数值。因此,现代 CPU 支持实数运算的近似:浮点运算。为了表示实数,大多数浮点格式采用科学记数法,并使用一定数量的位来表示尾数,较少的位来表示指数。

例如,在数字 3.456e+12 中,尾数是 3.456,而指数位是 12。因为计算机中的位数是固定的,所以计算机只能表示尾数中的某些数字(称为 有效数字)。例如,如果浮动点表示只能处理三个有效数字,那么 3.456e+12 中的第四位数字(6)就无法准确表示,因为三个有效数字只能正确表示 3.45e+12 或 3.46e+12。

因为基于计算机的浮动点表示也使用有限的位数来表示指数,所以该指数也有一个有限的值范围,大约从单精度格式的 10 ^(± 38) 到双精度格式的 10 ^(± 308)。这被称为值的动态范围。非标准化数(我稍后会定义)可以表示小到 ±4.94066 × 10^(–324) 的值。

6.7.2 有限精度算术与准确性

浮动点算术的一个大问题是它不遵循标准的代数规则。标准代数规则仅适用于 无限精度 算术。因此,如果你将一个代数公式转化为代码,这段代码可能会产生与你(数学上)期望的不同结果。这可能会在你的软件中引入缺陷。

考虑简单的语句 x = x + 1,其中 x 是一个整数。在任何现代计算机上,这个语句遵循正常的代数规则,只要没有溢出发生。也就是说,这个语句仅对某些 x 的值有效(minintx < maxint)。大多数程序员对此不会有问题,因为他们非常清楚程序中的整数并不遵循标准的代数规则(例如,5 / 2 不等于 2.5)。

整数不遵循标准的代数规则,因为计算机用有限的位数表示它们。你无法表示任何超出最大整数或小于最小整数的(整数)值。浮动点值也有这个问题,而且问题更严重。毕竟,整数是实数的一个子集。因此,浮动点值必须表示同一无限集合的整数。然而,在任何两个整数之间存在无限多个实数值。除了必须将值限制在最大和最小范围之间之外,你还无法表示任何一对整数之间的所有值。

为了演示有限精度算术的影响,本章采用了一种简化的十进制浮动点格式作为我们的示例。该格式提供了一个三位有效数字的尾数和一个两位数的十进制指数。尾数和指数都是有符号值,如图 6-1 所示。

图 6-1:浮动点格式

在进行科学计数法的加减法时,必须调整两个值,使它们的指数相同。乘法和除法则不要求指数相同;相反,乘法后的指数是两个操作数指数的和,而除法后的指数是被除数和除数指数的差。

例如,当加上 1.2e1 和 4.5e0 时,你必须调整这两个值,使它们具有相同的指数。实现这一点的一种方法是将 4.5e0 转换为 0.45e1,然后相加,得到 1.65e1。由于计算和结果只需要三位有效数字,因此你可以通过图 6-1 中所示的表示法来计算正确的结果。

然而,假设你要将两个值 1.23e1 和 4.56e0 相加。尽管这两个值都可以使用三位有效数字格式表示,但计算和结果并不适合三位有效数字。也就是说,1.23e1 + 0.456e1 需要四位精度才能计算出正确的结果 1.686,因此你必须将结果四舍五入截断为三位有效数字。四舍五入通常能产生最准确的结果,因此应将结果四舍五入为 1.69e1。

事实上,四舍五入并不是在两个值相加后发生的(即,得到和 1.686e1 并四舍五入为 1.69e1),而是在将 4.56e0 转换为 0.456e1 时发生的,因为保持值 0.456e1 需要四位精度。因此,在转换过程中,你必须将 0.456e1 四舍五入为 0.46e1,以便结果适应三位有效数字。1.23e1 和 0.46e1 的和然后得出最终四舍五入的和 1.69e1。

如你所见,精度(计算中保持的数字或位数)影响了准确性(计算的正确性)。在加法/减法示例中,由于你在计算过程中保持了四个有效数字计算中(具体来说,在将 4.56e0 转换为 0.456e1 时),你可以对结果进行四舍五入。如果你的浮点计算在计算过程中只保留了三位有效数字,你就不得不截断较小数字的最后一位,得到 0.45e1,并得出 1.68e1 的和,这个结果的准确性更低。

为了提高浮点计算的精确度,保持一个或多个额外的数字用于计算中是有用的,例如将 4.56e0 转换为 0.456e1 时使用的额外数字。计算过程中可用的额外数字被称为保护数字(在二进制格式中称为保护位)。它们在长链计算中能大大提高精度。

6.7.3 浮点计算中的误差

在一系列浮点数运算中,误差可能会积累,并极大地影响计算结果。例如,假设你将 1.23e3 加到 1.00e0。若在加法前调整数字,使它们的指数相同,就会变成 1.23e3 + 0.001e3。即使进行四舍五入,这两个数的和仍然是 1.23e3。这看起来似乎很合理;毕竟,你只能保留三位有效数字,因此加一个小的数值不应该影响结果。

然而,假设你将 1.00e0 加到 1.23e3 上十次(尽管这些操作不是在同一个计算中进行的,在计算过程中,保护数字可以保持第四位数字)。第一次将 1.00e0 加到 1.23e3 时,你得到 1.23e3。第二次、第三次、第四次……直到第十次,你仍然得到相同的结果 1.23e3。另一方面,如果你先将 1.00e0 加到自身十次,再将结果(1.00e1)加到 1.23e3,你将得到不同的结果:1.24e3。请记住,有限精度运算中的这个重要原则:

在进行复杂运算时,注意评估顺序,因为它可能会影响结果的准确性。

如果浮点数的相对大小(即指数)接近,当进行加法和减法时,你将得到更准确的结果。如果你进行的是包含加法和减法的连锁计算,尽量适当分组这些数值。

在计算加法和减法时,你还可能遇到虚假的精度。考虑计算 1.23e0 – 1.22e0,结果为 0.01e0。虽然该结果在数学上等同于 1.00e–2,但后一种形式暗示最后两位数字正好为 0。遗憾的是,此时你只有一个有效数字(记住,原始结果是 0.01e0,那两个前导 0 是有效数字)。实际上,一些浮点单元(FPU)或软件包可能会在低位位置插入随机数字(或位)。这突出了关于有限精度运算的第二条重要规则:

在进行两个符号相同的数字相减(或两个符号不同的数字相加)时,请注意结果可能包含高阶的有效数字(位),这些数字为 0。这会使最终结果的有效数字(位)减少相同的数量。如果可能的话,尽量调整你的计算方式,避免这种情况。

单独来看,乘法和除法并不会产生特别差的结果。然而,它们往往会放大已经存在的误差。例如,如果你应该将 1.24e0 乘以 2,但却将 1.23e0 乘以 2,那么结果会更加不准确。这也导致了关于有限精度运算的第三条重要规则:

在进行一连串包含加法、减法、乘法和除法的计算时,尽量先执行乘法和除法操作。

通常,通过应用正常的代数变换,你可以调整计算顺序,使得乘法和除法操作优先执行。例如,假设你想计算 x * (y + z)。通常,你会先将 yz 相加,然后将它们的和乘以 x。然而,如果你将 x * (y + z) 转换为 x * y + x * z 并首先进行乘法运算,你的结果将更加精确。当然,缺点是你现在需要执行两次乘法而不是一次,所以结果可能会更慢。

乘法和除法也有其问题。当乘以两个非常大或非常小的数字时,溢出下溢 很可能发生。当除以一个小数和一个大数,或者将大数除以一个小(分数)数时,也会发生类似的情况。这就引出了我们在乘法或除法中应该遵循的第四条规则:

在乘法和除法运算中,尽量安排乘法,使得它们将大数和小数相乘;同样,尝试将相对大小相同的数进行除法运算。

6.7.4 浮点值比较

由于任何计算中都会存在不准确性(包括将输入字符串转换为浮点值),你永远不要比较两个浮点值是否相等。在二进制浮点格式中,不同的计算结果可能会在最不重要的位上有所不同,即使它们产生相同的(数学)结果。例如,1.31e0 + 1.69e0 应该产生 3.00e0。同样,1.50e0 + 1.50e0 也应该产生 3.00e0。然而,如果你将 (1.31e0 + 1.69e0) 与 (1.50e0 + 1.50e0) 进行比较,你可能会发现这些和相等。

只有当两个操作数的所有位(或数字)完全相同时,平等性测试才会成功。因为在两个不同的浮点计算中,它们应该产生相同的结果,但这不一定成立,直接的平等性测试可能无法正常工作。因此,使用以下测试:

if `Value1` >= (`Value2` - `error`) and `Value1` <= (`Value2` + `error`) then ...

另一种常见的方法来处理相同的比较是使用如下形式的语句:

if abs(`Value1` - `Value2`) <= `error` then ...

在这些语句中,误差应该是一个稍大于你计算中可能出现的最大误差值的数值。确切的值将取决于你使用的具体浮点格式。简而言之,遵循以下最终规则:

在比较两个浮点数时,始终比较一个值,看看它是否在第二个值加上或减去一个小的误差值的范围内。

使用浮点值时可能会出现许多其他小问题。本书仅指出了一些主要问题,并会提醒你,由于有限精度运算中的不准确性,你不能像对待实数运算一样对待浮点运算。有关数值分析或科学计算的好书可以帮助你了解这些细节。如果你计划在任何语言中处理浮点运算,花时间研究有限精度运算对计算的影响(见第 6.13 节,“更多信息”,第 352 页)。

现在你已经了解了浮点运算的理论,接下来我们将回顾 ARM 对浮点运算的实现。

6.8 ARM 上的浮点运算

当 ARM CPU 首次设计时,浮点运算是 RISC CPU 回避的“复杂”指令之一。需要浮点运算的人不得不通过软件实现它。随着时间的推移,人们逐渐意识到高性能系统需要快速的浮点运算,因此它被加入到了 ARM 的指令集中。

ARM64 支持 IEEE 单精度和双精度浮点格式(见第 2.13 节,“IEEE 浮点格式”,第 93 页),以及在 IEEE 标准后期版本中出现的 16 位半精度浮点格式。为了支持浮点运算,ARM 提供了额外的寄存器组,并通过合适的浮点指令增强了指令集。最初,这些类型的指令由协处理器处理——协处理器是专门处理浮点指令的芯片(而主 CPU 处理整数运算)。在 ARM64 架构中,FPU(浮点单元)被集成进主 CPU 的集成电路中。

以下小节介绍了浮点寄存器集、浮点状态寄存器和浮点控制寄存器。这些是 ARM CPU 上浮点硬件中程序员可见的组件。

6.8.1 Neon 寄存器

为了支持浮点运算,ARM64 提供了第二组专门设计用来存储浮点和其他值的 32 个寄存器。这些被称为Neon 寄存器,因为除了支持标量浮点(FP)运算外,它们还支持使用 Neon 指令集扩展进行的向量运算,相关内容见第十一章。

32 个主要的 FP/Neon 寄存器每个为 128 位。就像通用寄存器根据其大小(Wn和 Xn)被分为两组一样,FP/Neon 寄存器也根据大小分为五组:

V0 到 V31    128 位的向量寄存器(用于 Neon 指令),也称为 Q0 到 Q31,qword 寄存器。Vn名称支持向量操作的特殊语法。

D0 到 D31    64 位的双精度浮点寄存器。

S0 到 S31    32 位的单精度浮点寄存器。

H0 到 H31    16 位的半精度浮点寄存器。

B0 到 B31    8 位的字节寄存器。

除了 32 个主寄存器外,该集合还包括两个特殊用途的浮点寄存器:浮点状态寄存器(FPSR)和浮点控制寄存器(FPCR),如图 6-2 所示。在以下小节中你将进一步了解这些寄存器。

图 6-2:FP/Neon 寄存器

Bn、Hn、Sn、Dn和 Vn寄存器互相覆盖,如图 6-3 所示。

图 6-3:FP/Neon 寄存器覆盖

由于历史原因,偶数编号的单精度寄存器(S0, S2, ..., S30)被映射到 D0 到 D15 中的第 0 到第 31 位,奇数编号的单精度寄存器被映射到第 32 到第 64 位。没有 Sn寄存器映射到 D16 到 D31(见图 6-4)。

图 6-4:Sn 寄存器如何覆盖 Dn 寄存器

以下章节主要集中在 Dn和 Sn寄存器集。本书不深入讨论半精度浮点算术,因为它主要用于图形处理单元(GPU)和某些图形例程。浮点硬件实际上并不处理半精度值——它仅允许你在半精度和单精度或双精度值之间转换。

大多数 ARM 浮点指令作用于 Dn或 Sn寄存器。本章将这些寄存器统称为 Fn,意味着你可以将任何双精度或单精度寄存器替代为 Fn。我还会根据需要指出例外情况。向量寄存器(Vn)是第十一章的内容。

6.8.2 控制寄存器

浮点控制寄存器(FPCR)指定某些浮点操作如何进行。尽管该寄存器是 32 位的,但只有 6 个位被使用,正如你在图 6-5 中所看到的。

图 6-5:FPCR 布局

表 6-9 描述了这些位的含义。

表 6-9:FPCR 位

位(s) 名称 描述
19 FZ16 半精度算术的零值清除模式。0 = 禁用,1 = 启用。此模式将非规范化值替换为 0。结果可能不如精确,但指令可能执行得更快。
22, 23 Rmode 舍入模式:00 = 四舍五入到最接近的值,01 = 向+∞舍入,10 = 向-∞舍入,11 = 截断(向 0 舍入)。
24 FZ 单精度和双精度算术的零值清除模式。
25 DN 默认 NaN(非数字)模式。0 = 禁用默认 NaN 模式,1 = 启用。当禁用时,NaN 会在算术操作中传播;当启用时,无效操作返回默认 NaN。
26 AHP 备用半精度位。启用(1)备用半精度模式或(0)IEEE 半精度模式。

在大多数情况下,你会将这些位保持为 0。当你希望截断而不是舍入浮点计算时,将 Rmode 设置为 0b11 是一个合理的变化。

要操作 FPCR 寄存器,请使用 mrs(将系统移动到寄存器)和 msr(将寄存器移动到系统)指令,指定 FPCR 作为系统寄存器:

mrs  X`n`, FPCR  // Copies FPCR to X`n`
msr  FPCR, X`n`  // Copies X`n` to FPCR

例如,要清除 FPCR 中的所有(已定义)位,可以使用以下指令:

mrs  x0,   fpcr
mov  x1,   #0xffff          // Load 0xf836ffff into X1, which is
movk x1,   #0xf836, lsl #16 // not a valid logical instr immediate value.
and  x0,   x0, x1           // Must put it in a register.
msr  fpcr, x0

使用以下指令将舍入模式设置为截断:

mrs  x0, fpcr
orr  x0, x0, #0x00c00000 // Is valid logical instr immediate value
msr  fpcr, x0

默认的 FPCR 设置在热重启时未知,因此在执行浮点操作之前,你应始终初始化此寄存器。

6.8.3 状态寄存器

FPSR 保存关于 ARM 浮点硬件的状态信息。读取此寄存器提供当前的浮点状态,而写入它则可以清除异常条件。尽管这是一个 32 位寄存器,但仅定义了 11 个位,实际上在 64 位模式下仅使用其中 7 个位(参见图 6-6)。

图 6-6:FPSR 布局

表 6-10 描述了 FPSR 中每个位的用途。

表 6-10:FPSR 位

名称 定义
0 IOC 无效操作累计标志。当一个操作的结果没有数学意义或无法表示时,设置此位。
1 DZC 除零累计标志。当发生除零操作时,设置此位。
2 OFC 溢出累计标志。当浮点操作导致溢出时,设置此位。
3 UFC 下溢累计标志。当在算术操作中发生下溢时,设置此位。
4 IXC 不精确累计标志。当浮点操作产生不精确结果时,通常会设置此位。
7 IDC 输入非正规化累计标志。当一个非正规化的输入操作数在计算中被替换为零时,设置此位。
27 QC 饱和累计标志。当饱和指令剪裁一个值时,设置此标志。有关饱和指令的讨论,请参见第十一章。
28–31 N, C, Z, V 这些标志仅在 32 位模式下使用。在 64 位模式下,浮点比较和其他指令直接设置 PSTATE 寄存器中的 N、Z、C 和 V 标志。

你可以通过 mrs 和 msr 指令读取和写入 FPSR,使用 FPSR 作为系统寄存器名称。读取 FPSR 以确定是否发生了任何浮点异常,写入 FPSR 则可以清除异常位(通过将 0 写入寄存器中的受影响位)。例如,以下代码清除 FPSR 中的无效操作累计标志:

mrs  x0, FPSR
and  x0, x0, #-2  // Clear IOC bit (-2 is 0xFFFF...FE).
msr  FPSR, x0

6.9 浮点指令

FPU 向 ARM 指令集添加了许多指令。我将这些指令分类为数据传输指令、转换指令、算术指令、比较指令和杂项指令。本节描述了这些类别中的每条指令。

6.9.1 FPU 数据传输指令

数据传输指令在内部 FPU 寄存器和内存之间传输数据。该类别的指令包括 ldr/ldur、str/stur、ldp/ldnp、stp/stnp 和 fmov。

6.9.1.1 ldr/ldur 和 str/stur

ldr 和 str 指令从内存位置加载一个 FPU 寄存器,使用正常的内存寻址模式。ldur/stur 指令强制执行一个未缩放的加载或存储操作,适用于汇编器可能选择缩放的间接加偏移模式的情况。通常,你会让汇编器为你选择合适的底层机器编码,而不是使用 ldur/stur。

使用此指令时,你可以指定任何 FPU 寄存器的名称。例如,以下代码从内存中加载指定的浮点寄存器:

ldr q0, [x0]  // Loads 128 bits from memory
ldr d0, [x0]  // Loads 64 bits from memory
ldr s0, [x0]  // Loads 32 bits from memory
ldr b0, [x0]  // Loads 8 bits from memory
6.9.1.2 ldp/ldnp 和 stp/stnp

ldp 和 stp 指令的工作方式类似于它们的整数对应指令,作用于浮点寄存器:它们一次加载或存储一对寄存器。这些指令不支持 Hn 或 Bn 寄存器;你只能使用这些指令加载字(word)、双字(dword)或四字(qword)FPU 寄存器。

以下示例演示了如何从内存中加载 256 位、128 位和 64 位的数据:

ldp q0, q1, [x0]  // Loads 256 bits from memory
ldp d0, d1, [x0]  // Loads 128 bits from memory
ldp s0, s1, [x0]  // Loads 64 bits from memory

ldnp 和 stnp 指令执行非暂时性加载和存储操作。这通知 CPU 你不打算在不久的将来再次访问指定的内存位置,因此 CPU 不会将数据复制到缓存中(这是汇编语言中能做的,而高级语言中无法做到的一个便利示例)。这可以通过帮助防止称为 thrashing 的情况,从而提高性能,在这种情况下,CPU 不断地将数据从缓存中移入移出。

6.9.1.3 fmov

fmov 指令在两个大小相同的浮点寄存器之间(这两个寄存器都是 32 位或 64 位)或在一个 32 位或 64 位通用寄存器(GP 寄存器)和一个大小相同的浮点寄存器之间传输数据。以下是该指令的允许语法:

fmov S`d`, S`n`  // Move data between two 32-bit FP registers.
fmov D`d`, D`n`  // Move data between two 64-bit FP registers.
fmov S`d`, W`n`  // Move data from a 32-bit GP to an FP register.
fmov W`d`,  S`n`  // Move data from a 32-bit FP to a GP register.
fmov D`d`, X`n`  // Move data from a 64-bit GP to an FP register.
fmov X`d`,  D`n`  // Move data from a 64-bit FP to a GP register.

将通用寄存器的值移动到浮点寄存器并不会将 GP 寄存器中的整数值转换为浮点值;这种 fmov 操作假定 GP 寄存器包含的是浮点数的位模式。同样,将浮点寄存器的值移动到通用寄存器也不会将浮点值转换为整数。

6.9.1.4 带立即数操作数的 fmov

ARM 提供了一个 fmov 指令,允许使用非常有限的立即数操作数。其语法如下:

fmov S`d`, #`fimm`
fmov D`d`, #`fimm`

其中 fimm 是一个来自非常小集合的浮点常量。允许的值是 ±n / 16 × 2^m,其中 16 ≤ n ≤ 31 且 -3 ≤ m ≤ 4。这意味着你可以表示诸如 1.0 或 -2.0 这样的值,但不能表示 1.2345e5。

你不能通过这种立即数形式表示值 0.0。然而,你可以通过以下两条指令之一将 0.0 加载到浮点寄存器中:

fmov S`d`, wzr
fmov D`d`, xzr

如果你想将一个任意浮点常数加载到寄存器中,你需要将该常数放入一个内存位置,使用 .single 或 .double 指令,然后从该位置加载寄存器。遗憾的是,ldr 指令不接受浮点立即数操作数:

ldr d0, =10.0   // Generates an error

幸运的是,PC 相对寻址模式是有效的,因此你可以访问在 .text 区段中初始化的内存位置(最好在 .pool 区域中),如下例所示:

 .code
        .pool
fp10:   .double 10.0
         .
         .
         .
        ldr     d0, fp10

通过添加 .pool 指令,Gas 还可以将其他汇编生成的常数嵌入到这个区域中。

6.9.2 FPU 算术指令

ARM CPU 提供了一大套浮点指令,能够对单精度和双精度浮点值进行操作。与整数操作一样,这些指令大多数都需要三个(浮点)寄存器操作数:一个目标寄存器,一个左侧源寄存器和一个右侧源寄存器。

表 6-11 列出了算术指令的语法。在此表中,Fd、Fn、Fm 和 Fa 代表浮点寄存器,依据指令的精度,可能是 Sn 或 Dnn = 0 到 31)。对于给定的指令,所有寄存器必须具有相同的大小(32 位或 64 位)。

表 6-11:浮点算术指令

指令 操作数 描述
fadd Fd, Fn, Fm Fd = Fn + Fm
fsub Fd, Fn, Fm Fd = Fn – Fm
fmul Fd, Fn, Fm Fd = Fn × Fm
fnmul Fd, Fn, Fm Fd = –(Fn × Fm)
fmadd Fd, Fn, Fm, Fa Fd = Fa + Fn × Fm
fmsub Fd, Fn, Fm, Fa Fd = Fa – Fn × Fm
fnmadd Fd, Fn, Fm, Fa Fd = –(Fa + Fn × Fm)
fnmsub Fd, Fn, Fm, Fa Fd = –(Fa – Fn × Fm)
fdiv Fd, Fn, Fm Fd = Fn / Fm
fmax Fd, Fn, Fm Fd = max(Fn, Fm),若任一操作数为 NaN,则结果为 NaN
fmaxnm Fd, Fn, Fm Fd = max(Fn, Fm),若另一个操作数为(安静的)NaN,则结果为数字
fmin Fd, Fn, Fm Fd = min(Fn, Fm),若任一操作数为 NaN,则结果为 NaN
fminnm Fd, Fn, Fm Fd = min(Fn, Fm),若另一个操作数为(安静的)NaN,则结果为数字
fabs Fd, Fn Fd = fabs(Fn),绝对值
fneg Fd, Fn Fd = –Fn
fsqrt Fd, Fn Fd = sqrt(Fn)

许多操作可能会引发某种异常。例如,fdiv 操作会在发生除以 0 的情况下设置 FPSR 中的 DZC 标志。一些操作,如 fsqrt,可能会产生无效结果——例如,当尝试对负数取平方根时。在一系列浮点指令执行后,检查 FPSR 以查看所获得的结果是否有效。FPSR 位是粘性的,一旦发生异常,它们会保持设置状态;这使得你可以在计算链条的末尾检查错误,而不必在每条浮点指令后都进行检查。

6.9.3 浮点比较

ARM 提供了一个浮点比较和条件比较指令。两者都有几种形式。

fcmp   F`d`, F`s` 
fcmpe  F`d`, F`s` 
fcmp   F`d`, #0.0 
fcmpe  F`d`, #0.0 

fccmp  F`d`, F`s`, #`nzcv`, `cond` 
fccmpe F`d`, F`s`, #`nzcv`, `cond` 

其中 nzcv 和 cond 的含义与 ccmp 指令中的相同。

带有 e 后缀的指令在比较过程中如果任一操作数是 NaN,会触发异常。处理这些指令引发的异常超出了本书的范围,因此后续示例代码仅使用不带 e 后缀的形式。

fcmp 指令将一个 FPU 寄存器与另一个 FPU 寄存器或立即数 0.0 进行比较。如果你需要与其他浮点常数进行比较,你必须先将其加载到一个寄存器中。请注意,fccmp 不提供允许与 0.0 比较的形式(尽管你可以将 XZR 或 WZR 复制到另一个 FPU 寄存器,并与其进行比较)。

6.9.3.1 比较逻辑

fcmp 指令根据比较设置 (PSR,而非 FPSR) 条件码位 N、Z、C 和 V,允许你使用条件分支和其他条件指令来测试比较结果。然而,这些设置的行为与整数比较有所不同。首先,浮点值始终是有符号的,因此没有无符号和有符号的比较;其次,浮点比较可能是无序的。

无序比较发生在你比较的两个值中有一个或两个是 NaN,因为在这种情况下,这两个值是不可比较的。最多,你可以说它们不相等;更安全的做法是直接说结果是无序的,就这么处理。通常,如果比较的结果是无序的,说明出现了严重问题,你需要采取纠正措施。

避免此问题的一种方法是使用 fcmpe 形式,它可以生成异常,并让异常处理程序处理无序值。然而,如前所述,处理这些异常超出了本书的范围,因此我建议坚持使用 fcmp。

fcmp 指令设置 N、Z、V 和 C 标志,以便在比较后可以测试这些标志来判断有序或无序结果。好消息是,你可以通过使用正常的条件分支和其他指令来处理有序和无序的比较。坏消息是,fcmp 结果稍微改变了这些条件分支指令的定义。表 6-12 描述了 fcmp 如何设置这些标志。

表 6-12: fcmp 设置的标志

条件 含义
EQ 相等
NE 不相等,或无序
GE 大于或等于
LT 小于,或无序
GT 大于
LE 小于或等于,或无序
HI 大于,或无序
HS/CS 大于或等于,或无序
LO/CC 小于
LS 小于或等于
MI 小于
PL 大于或等于,或无序
VS 无序
VC 有序

表 6-12 中有两点很容易被忽略:

  • 如果比较结果是无序的,fcmp指令会设置 V 标志。

  • 浮点比较使用有符号和无符号测试,而浮点值本身是有符号值。

你会注意到,GE 和 GT 是有序比较,而 LE 和 LT 处理无序比较。同样,LS 和 LO 是有序比较,而 HI 和 HS 也处理无序比较。乍一看,这可能有些奇怪;为什么不把一个集合(有符号或无符号)设置为有序,另一个集合设置为无序呢?

然而,你需要两个相反的测试(例如,LE 和 GT,或者 LT 和 GE)来处理所有可能的结果。某个结果是无序的。因此,其中一个相反的比较需要处理无序情况,这样每对测试就能全面覆盖条件(同样的逻辑适用于 HI-LS 和 HS-LO)。你始终可以测试溢出标志(V)来查看比较是有序还是无序。

6.9.3.2 条件比较

条件浮点比较指令fccmp是整数条件比较指令的浮点类对应物。你可以使用它来简化涉及合取(AND)和析取(OR)的复杂布尔表达式,如前面所述(参见第 6.5 节,“条件比较和布尔表达式”,第 314 页)。

6.9.3.3 相等比较

如第 6.7 节“浮点数与有限精度算术”(第 322 页)所讨论的,你在比较两个浮点值时(特别是相等比较)要非常小心。两个计算中的微小误差,在使用无限精度实数算术时可能产生相同的结果,但在使用有限精度浮点算术时可能会得出不同的结果。如果你想比较两个值是否相等,应该计算它们的差值,并确定其绝对值是否在可接受的误差范围内。

关键问题是如何确定误差的可接受范围。因为这些(假定相等的)浮点值之间的差异会表现在尾数的低位(LO 位),所以误差值应该对应于这些位置中的某个 1 位。

示例 6-2 展示了如何计算这个误差值。

// Listing6-2.S 
//
// Demonstrate comparing two floating-point 
// values for equality by using a difference 
// and error range comparison. 

        #include    "aoaa.inc"

// The following bit mask will keep the 
// exponent bits in a 64-bit double-precision 
// floating-point value. It zeros out the 
// remaining sign and mantissa bits. 

❶ maskFP  =       0x7FF0000000000000 

// bits is the number of bits you want to 
// mask out at the bottom of the mantissa. 
// It must be greater than 0: 

❷ bits    =       4 
bitMask =       (1 << bits)-1 

// expPosn is the position of the first 
// exponent bit in the double-precision 
// format: 

expPosn =       52 

        .text 
        .pool 
ttlStr: wastr   "Listing 6-2"
fmtStr: wastr   "error for (%24.16e) = %e\n"
difMsg: wastr   "Difference:%e\n"
values: wastr   "Value1=%23.16e, Value2=%23.16e\n"
eqMsg:  wastr   "Value1 == Value2\n"
neMsg:  wastr   "Value1 != Value2\n"

// When value2 is somewhere between 
// 8e-323 and 9e-323, the 
// comparison becomes not equal: 

value1: .double 1.0e-323 
value2: .double 9e-323 

// Generic values to compare: 

// value1: .double   1.2345678901234567 
// value2: .double   1.234567890123456 

// getTitle 
//
// Return pointer to program title 
// to the C++ code: 

        proc    getTitle, public 
        lea     x0, ttlStr 
        ret 
        endp    getTitle 

// computeError 
//
// Given a double-precision floating-point 
// value in D0, this function computes an 
// error range value for use in comparisons. 
// If the difference between two FP values 
// (one of which is the value passed in D0) 
// is less than the error range value, you 
// can consider the two values equal. 

      ❸ proc    computeError 

        // Preserve all registers this code 
        // modifies: 

        locals  ce 
 qword   ce.saveX01 
        byte    stack, 64 
        endl    ce 

        enter   ce.size 
        stp     x0, x1, [fp, #ce.saveX01] 

        // Move the FP number into X0 so you can mask 
        // bits: 

        fmov    x0, d0 

        // Generate mask to extract exponent: 

      ❹ and     x0, x0, #maskFP     // Extract exponent bits. 
        lsr     x1, x0, #expPosn    // Put exponent in bits 0-10\. 

        // We need to normalize the value, 
        // if possible: 

      ❺ cmp     x1, #(expPosn - bits - 1) 
        blo     willBeDenormal 

        // If the result won't be a subnormal 
        // (denormalized value), then set 
        // the mantissa bits to all 0s 
        // (plus the implied 1 bit) and 
        // decrement the exponent to move 
        // the "bits" position up to the 
        // implied bit: 

      ❻ sub     x1, x1, #expPosn-bits // Adjust exponent. 
        lsl     x0, x1, #expPosn      // Put exponent back. 
        b.al    allDone 

// If the result will be denormalized, handle that 
// situation down here: 

❼ willBeDenormal: 
        mov     x0, #bitMask 
        lsl     x0, x0, x1  // Shift as much as you can. 

allDone: 
        fmov    d0, x0      // Return in D0\. 
        ldp     x0, x1, [fp, #ce.saveX01] 
        leave 
        endp    computeError 

///////////////////////////////////////////////////////// 
//
// Here's the asmMain procedure: 

        proc    asmMain, public 

        locals  am 
        double  am.error 
 double  am.diff 
        byte    am.stackSpace, 64 
        endl    am 

        enter   am.size 

// Display the values you're going to compare: 

        ldr     d0, value1 
        str     d0, [sp] 
        ldr     d1, value2 
        str     d1, [sp, #8] 
        lea     x0, values 
        bl      printf 

// Compute the error value: 

        ldr     d0, value1 
        bl      computeError 
        str     d0, [fp, #am.error] 

// Print the error value: 

        str     d0, [sp, #8] 
        ldr     d1, value1 
        str     d1, [sp] 
        lea     x0, fmtStr 
        bl      printf 

// Compute the difference of the 
// two values you're going to compare 
// and print that difference: 

        ldr     d0, value1 
        ldr     d1, value2 
        fsub    d0, d0, d1 
        str     d0, [fp, #am.diff] 
        str     d0, [sp] 
        lea     x0, difMsg 
        bl      printf 

// Compare the difference of the two 
// numbers against the error range. 

        ldr     d1, [fp, #am.error] 
        ldr     d0, [fp, #am.diff] 
        fabs    d0, d0              // Must be abs(diff)! 
        fcmp    d0, d1 
        ble     isEqual 

// Print whether you should 
// treat these values as equal: 

        lea     x0, neMsg 
        b.al    printIt 

isEqual: 
        lea     x0, eqMsg 
printIt: 
        bl      printf 

        leave                       // Return to caller. 
        endp    asmMain 

当将掩码 0x7FF0_0000_0000_0000 ❶与双精度浮点值做与运算时,它会剥离出尾数和符号位,保留在第 52 到 62 位(11 位指数)的位置上的指数。

本清单中的位常量❷确定在生成误差值时代码将消除尾数中低位(LO 位)的数量(当前为 4 位,因此尾数的 4 个低位变得不重要,但在大多数情况下,对于单精度比较应为 2 到 3 位,对于双精度比较应为 3 到 4 位)。一旦 computeError 函数生成误差值,主程序就会使用该误差值来比较两个浮点数,并报告它们是否应该视为相等(它们的差异小于误差值)或不相等(它们的差异较大)。bitMask 值只是由 1 位组成的字符串(在清单 6-2 中为 4 位)。

程序 computeError❸接收一个浮点值到 D0 寄存器。此函数为该浮点数计算误差值,以便在与第二个数字比较时,如果它们应该被视为相等,其差异将小于误差值。此函数将误差值返回到 D0 寄存器。

为了计算误差值,computeError 首先将指数移到位 0 到 10,以便更容易操作❹。如果指数小于 52 – 5 位,则误差值将变为一个次规范(非规范化)数字。代码确定误差值将是规范化还是次规范❺。

如果结果将是一个规范化的数字,代码会通过 52 位生成误差值(如果位数是 4,则为 47 位),然后将指数移回到其正确的位置❻。尾数和符号位将全部为 0;然而,双精度数字的隐含位将为 1,因为指数不为 0。

如果误差值变为次规范,代码将指数设为 0,表示一个非规范化值,并将 bitMask 值向左移动指定的位数,位数由指数减去位数值❼确定。

这是清单 6-2 的构建命令和示例输出:

$ ./build Listing6-2 
$ ./Listing6-2 
Calling Listing6-2: 
Value1 = 9.8813129168249309e-324, Value2 = 8.8931816251424378e-323 
error for (7.4109846876186982e-323) = 9.881313e-324 
Difference:-7.905050e-323 
Value1 != Value2 
Listing6-2 terminated 

这表明,Value1 和 Value2 之间的差异肯定超出了此比较所允许的误差范围。

6.9.3.4 条件选择指令

尽管 ARM 不支持整数指令集中所有的条件指令,但它支持最常用的条件指令:条件选择指令,或称 fcsel。fcsel 指令的语法如下:

fcsel F`d`, F`t`, F`f`, `cond`

该指令将测试条件,如果条件为真,则将 Ft 复制到 Fd;如果条件为假,则将 Ff 复制到 Fd。

6.9.4 浮点数转换指令

ARM 指令集包含各种各样的指令,用于在不同的浮点格式之间以及在带符号/无符号整数和浮点格式之间进行转换。某些 CPU 甚至支持浮点与定点格式之间的转换。本节将描述这些转换。

6.9.4.1 fcvt

fcvt 指令在三种支持的浮点格式(半精度、单精度和双精度)之间进行转换。这是为数不多的支持 Hn 寄存器的指令之一(ldr 和 str 是其他支持的指令)。该指令的语法如下:

fcvt H`d`, S`s`
fcvt H`d`, D`s`
fcvt S`d`, H`s`
fcvt S`d`, D`s`
fcvt D`d`, H`s`
fcvt D`d`, S`s`

这些指令将其源操作数转换为目标操作数的类型,并将转换后的数据复制到目标操作数中。当然,并非所有转换都能毫无错误地发生——请注意,将较大尺寸格式转换为较小尺寸格式可能会导致下溢和下溢异常。在进行此类操作后,您可能需要考虑检查 FPSR:

fcvt s0, d1
mrs  x0, FPSR
mov  w1, #0x8c
ands w0, w0, w1  // UFC, OFC, and IDC bits
bne  badCvt

这段代码演示了如何检查 UFC、OFC 和 IDC 位,以查看转换后是否发生了错误。

6.9.4.2 浮点数与整数之间的转换

表 6-13 中的指令在各种浮点数(单精度和双精度)与整数格式之间进行转换。这些指令的语法如下:

fcvt{`m`}{s|u} R`d`, F`n`

其中 m 是 a、m、n、p 或 z,表示舍入模式(参见表 6-13,其中 FP = 浮点数,SI = 有符号整数,UI = 无符号整数)。Fn 表示任何单精度或双精度浮点寄存器,Rd 表示任何通用寄存器(Wd 或 Xd)。

表 6-13: fcvt{m}{s|u} 转换指令

指令 描述
fcvtas 将浮点数转换为有符号整数;舍入远离 0。
fcvtau 将浮点数转换为无符号整数;舍入远离 0。
fcvtms 将浮点数转换为有符号整数;舍入到负无穷(向下取整)。
fcvtmu 将浮点数转换为无符号整数;舍入到负无穷(向下取整)。
fcvtns 将浮点数转换为有符号整数;舍入到偶数(标准 IEEE 舍入)。
fcvtnu 将浮点数转换为无符号整数;舍入到偶数(标准 IEEE 舍入)。
fcvtps 将浮点数转换为有符号整数;舍入到正无穷(向上取整)。
fcvtpu 将浮点数转换为无符号整数;舍入到正无穷(向上取整)。
fcvtzs 将浮点数转换为有符号整数;舍入到 0(截断)。
fcvtzu 将浮点数转换为有符号整数;舍入到 0(截断)。

除了将浮点值转换为整数外,ARM 还提供了两条指令,将整数转换为浮点值:

scvtf F`d`, R`d`  // Same register meanings as for fcv*
ucvtf F`d`, R`d`  // instructions

scvtf 指令将有符号整数转换为浮点值,ucvtf 指令将无符号整数转换为浮点数。请注意,某些整数值不能被单精度或双精度值精确表示。例如,双精度浮点数有 56 位尾数,因此无法精确表示所有 64 位整数。

6.9.4.3 定点转换

一些 64 位 ARM CPU 支持将定点二进制值与浮点值之间进行转换。这些指令的形式如下:

fcvtzs R`d`, F`s`, #`bits`
fcvtzu R`d`, F`s`, #`bits`
scvtf  F`d`, R`s`, #`bits`
ucvtf  F`d`, F`s`, #`bits`

这里,bits 是通用寄存器中二进制点右侧的位数。它是一个常量,范围从 0 到比通用寄存器大小少 1。例如,在一个 64 位寄存器中,32 的值表示二进制点左右各有 32 位的固定点数。

6.9.4.4 舍入

ARM 提供了几条浮点舍入指令。它们与浮点到整数的转换类似,都是将实数舍入为整数值。然而,这些指令产生的不是二进制整数值,而是浮点结果,这些结果恰好是整数(或者说是这些整数的浮点表示)。

这些指令都将一对浮点寄存器作为操作数。两个寄存器必须具有相同的大小(单精度或双精度)。通用语法如下:

frint{`m`} F`d`, F`s`  // Both registers must be S`n` or D`n`.

指令描述出现在 表 6-14 中。

表 6-14:frint{m} 指令

指令 描述
frinta 向 0 远离舍入。
frinti 使用 FPCR 中的 Rmode 设置进行舍入。
frintm 向负无穷方向舍入。
frintn 正常舍入,精确的 0.5 舍入到最近的偶数值。
frintp 向正无穷方向舍入。
frintx 使用 FPCR 模式进行舍入;如果值最初不是整数,则引发异常。
frintz 向 0 舍入。

现在,您已经回顾了浮点转换指令,我将向您展示如何在与其他程序接口的代码中使用浮点指令。

6.10 ARM ABI 与浮点寄存器

ARM ABI 将 V0 到 V7 和 V16 到 V31 视为易失性寄存器。调用者必须在过程调用之间保存这些寄存器的值,如果需要它们在调用之间保持不变。

寄存器 V8 到 V15 是非易失性的。如果被调用函数修改了这些寄存器的值,它必须在过程内部保存它们。当然,这些寄存器的优势在于,一旦过程保存了它们(为了调用者),它就不必担心任何它调用的函数修改这些寄存器。

调用者将前八个浮点参数通过寄存器传递给一个过程。当传递整数和浮点参数的组合时,调用者将非浮点参数通过通用寄存器(X0 到 X7)传递,浮点参数通过浮点寄存器传递。如果浮点参数的数量超过八个,调用者会将浮点参数放入栈中。

参数被分配到下一个可用的寄存器,而不是根据参数在参数列表中的位置分配寄存器号。考虑以下 C 函数原型:

void p
(
    int i,
    double d,
    int j,
    int k,
    double e,
    int l,
    double f,
    double g,
    double h
);

ARM ABI 会将 表 6-15 中的寄存器与这些正式参数关联:

表 6-15:寄存器的参数分配

寄存器 参数
X0 i
D0 d
X1 j
X2 k
D1 e
X3 l
D2 f
D3 g
D4 h

如果一个函数通过引用传递浮点参数,则该浮点值的地址将通过下一个可用的通用寄存器传递(对于通过引用传递的参数,没有浮点寄存器)。

如果一个函数返回浮点结果,它会将该值返回到 D0(或者 S0,如果语言支持将单精度浮点数作为函数返回结果)。有关将向量(多个浮点值)作为函数结果返回的详细信息,请参见第十一章(提示:V0)。如果一个函数返回一个浮动点值数组,则调用者必须为该数组分配存储空间,并通过 X8 传递该数组的指针。函数将在返回之前将结果存储到该存储空间中。

6.11 使用 C 标准库数学函数

尽管 ARM 指令集提供了一组计算基本算术运算的机器指令,但它没有用于计算复杂数学函数(如正弦、余弦和正切)的指令。你可以(如果具备相应的知识)用汇编语言编写这些函数,但有一个更简单的解决方案:调用已经为你编写的函数。特别是,C 标准库包含了许多有用的数学函数,你可以使用它们。本节将介绍如何调用其中的几个。

作为一个演示将浮点值传递给函数的示例程序,列表 6-3 调用了各种 C 标准库<math.h>函数(特别是 sin()、cos()和 tan())。这些函数中的每一个都接受一个双精度参数并返回一个双精度结果。

// Listing6-3.S
//
// Demonstrates calling various C stdlib
// math functions

#include    "aoaa.inc"

        .text
        .extern sin  // C stdlib functions
        .extern cos  // this program calls
        .extern tan

        .pool
ttlStr: wastr   "Listing 6-3"

// Format strings for each of the outputs:

piStr:  wastr   "%s(pi) = %20.14e\n"
pi2Str: wastr   "%s(pi/2) = %20.14e\n"
pi4Str: wastr   "%s(pi/4) = %20.14e\n"
pi8Str: wastr   "%s(pi/8) = %20.14e\n\n"

// Function names (printed as %s argument
// in the format strings):

sinStr: wastr   "sin"
cosStr: wastr   "cos"
tanStr: wastr   "tan"

// Sample values to print for each
// of the functions:

pi:     .double 3.141592653588979
pi2:    .double 1.5707963267949
pi4:    .double 0.7853981639745
pi8:    .double 0.39269908169872

// getTitle
//
// Return pointer to program title
// to the C++ code.

        proc    getTitle, public
        lea     x0, ttlStr
        ret
        endp    getTitle

// Trampolines to the C stdlib math functions.
// These are necessary because lea can't take
// the address of a function that could be
// very far away (as the dynamic libraries
// probably are).
//
// Note: Must use real "b" instruction here
// rather than "b.al" because external
// functions are likely out of range.

      ❶ proc    sinVeneer
        b       sin
        endp    sinVeneer

        proc    cosVeneer
        b       cos
        endp    cosVeneer

        proc    tanVeneer
        b       tan
        endp    tanVeneer

// doPi(char *X0, func X1)
//
// X0-  Contains the address of a function
//      that accepts a single double and
//      returns a double result.
// X1-  Contains the address of a string
//      specifying the function name.
//
// This function calls the specified function
// passing PI divided by 1, 2, 4, and 8 and
// then prints the result that comes back.

      ❷ proc    doPi

        locals  dp
        dword   dp.saveX1
        dword   dp.saveX0
        dword   dp.saveX19
        byte    dp.stackSpace, 64
        endl    dp

        // Set up activation record and save register values:

        enter   dp.size
        stp     x0, x1, [fp, #dp.saveX0]  // X1 -> saveX1, too
        str     x19, [fp, #dp.saveX19]    // Preserve nonvolatile.

        mov     x19, x0                   // Keep address in nonvolatile.

        // Call the function for various values
        // of pi/n:

      ❸ ldr     d0, pi
        blr     x19           // Call function.
        mstr    d0, [sp, #8]  // Save func result as parm.
        ldr     x1, [fp, #dp.saveX1]
        mstr    x1, [sp]
        lea     x0, piStr
        bl      printf

        ldr     d0, pi2
        blr     x19             // Call function.
        mstr    d0, [sp, #8]
        lea     x0, piStr
        ldr     x1, [fp, #dp.saveX1]
        mstr    x1, [sp]
        lea     x0, pi2Str
        bl      printf

        ldr     d0, pi4
        blr     x19             // Call function.
        mstr    d0, [sp, #8]
        lea     x0, piStr
        ldr     x1, [fp, #dp.saveX1]
        mstr    x1, [sp]
        lea     x0, pi4Str
        bl      printf

        ldr     d0, pi8
        blr     x19             // Call function.
        mstr    d0, [sp, #8]
        lea     x0, piStr
        ldr     x1, [fp, #dp.saveX1]
 mstr    x1, [sp]
        lea     x0, pi8Str
        bl      printf

        // Restore nonvolatile register
        // and return:

        ldr     x19, [fp, #dp.saveX19]
        leave
        endp    doPi

/////////////////////////////////////////////////////////
//
// Here's the asmMain procedure:

        proc    asmMain, public
        enter   64              // Generic entry

        // Load X0 with the address
        // of the veneer (trampoline) function
        // that calls the C stdlib math function,
        // load X1 with the function's name,
        // then call doPi to call the function
        // and print the results:

      ❹ lea     x0, sinVeneer   // SIN(x) output
        lea     x1, sinStr
        bl      doPi

        lea     x0, cosVeneer   // COS(x) output
        lea     x1, cosStr
        bl      doPi

        lea     x0, tanVeneer   // TAN(x) output
        lea     x1, tanStr
        bl      doPi

        leave                   // Return to C/C++ code.
        endp    asmMain

这个程序间接调用了 sin()、cos()和 tan()函数——特定函数的地址作为参数传递给 doPi 过程。不幸的是,macOS 的 PIE 功能阻止你通过使用 lea 宏来获取这样的函数的地址,因为无法预测操作系统在运行时会将动态链接(共享)库加载到何处;它可能会远远超出 lea 所允许的±4GB 范围。因此,这段代码为这些函数创建了跳板,操作系统可以修补这些跳板以将控制转移到函数在内存中的位置❶。这些跳板仅对 macOS 必要;虽然它们在 Linux 代码中也能工作,但 Linux 允许你使用 lea 获取 C 标准库函数的地址。

doPi 函数❷将 X0、X1 和 X19 的值保存在激活记录中。保存 X19 是必要的,因为它是一个不可丢失的寄存器。保存 X0 和 X1 是必要的,因为该过程需要它们的值以进行 printf()调用,而这些寄存器是易失性的。

doPi 函数体会调用适当的函数(sin()、cos() 或 tan())四次,分别使用 π、π/2、π/4 和 π/8 作为输入值,然后显示这些函数返回的结果 ❸。注意 doPi 如何通过使用 blr 指令间接调用函数——函数的地址最初是通过 X0 寄存器传递给 doPi 的。

主要程序将跳板函数(veneer)地址加载到 X0 寄存器中,同时加载一个字符串指针,并调用 doPi 计算值并打印结果 ❹。(跳板函数和 veneer 在第七章中有进一步解释。)仅在 macOS 上需要将跳板函数的地址加载到 X0 寄存器;在 Linux 上,你可以直接加载 sin()、cos() 或 tan() 函数的地址,从而避免跳板函数带来的轻微低效。

以下是列表 6-3 的构建命令和示例输出:

$ ./build -math Listing6-3
$ ./Listing6-3
Calling Listing6-3:
sin(pi) = 8.14137986335080e-13
sin(pi/2) = 1.00000000000000e+00
sin(pi/4) = 7.07106781594585e-01
sin(pi/8) = 3.82683432365086e-01

cos(pi) = -1.00000000000000e+00
cos(pi/2) = -3.49148133884313e-15
cos(pi/4) = 7.07106780778510e-01
cos(pi/8) = 9.23879532511288e-01

tan(pi) = -8.14137986335080e-13
tan(pi/2) = -2.86411383293069e+14
tan(pi/4) = 1.00000000115410e+00
tan(pi/8) = 4.14213562373090e-01

Listing6-3 terminated

你会注意到这个构建命令和书中大多数其他命令之间的一个区别:-math 参数。这个参数告诉 Linux 链接 C 标准库中的数学库函数(macOS 会自动链接此库)。如果没有 -math 选项,当你尝试构建程序时会遇到链接错误。

C 标准库包含许多你可能觉得有用的双精度函数。你可以在网上查看它们的详细信息。许多这些函数在汇编语言中是不必要的,因为它们对应着一到两条机器指令。然而,库中包含了一些复杂的函数,这些函数你可能不想自己编写。

你可能会在网上找到一些声称比 C 标准库中的函数更快的各种函数。使用这些函数时要小心,因为它们通常不准确。如果你没有扎实的数值分析基础,最好不要尝试自己编写这些函数。

6.12 继续前进

本章涵盖了大量内容:剩余的算术指令(包括乘法、除法和取余,以及 cmp 和各种条件指令)、将变量保存在寄存器而非内存位置、以及正确使用易失性和非易失性寄存器。还讨论了如何创建结构以提供对全局变量的高效访问、将算术和逻辑表达式(整数和浮点)转换为机器指令等效项,并调用用 C/C++ 编写的函数。

拥有这些信息后,你现在可以将像 C/C++ 这样的高级语言中的算术表达式转换为 ARM 汇编语言。你编程技能中唯一缺失的基本技能是对汇编语言中的控制结构的良好理解,下一章你将学习这些内容。

6.13 获取更多信息

第七章:7 低级控制结构

本书至此为止的示例采用了临时的方法创建汇编控制结构。现在是时候规范化如何控制汇编语言程序的操作了。完成本章后,你应该能够将高级语言的控制结构转换为汇编语言控制语句。

汇编语言中的控制结构包括条件分支和间接跳转。本章将讨论这些指令以及如何模拟高级语言中的控制结构,例如 if...else、switch 和循环语句。本章还将讨论标签、条件分支和跳转语句的目标,以及标签在汇编语言源文件中的作用域。

7.1 语句标签

在讨论跳转指令以及如何使用它们来模拟控制结构之前,有必要深入讨论汇编语言中的语句标签。标签在汇编语言程序中充当地址的符号名称。使用像 LoopEntry 这样的名称来引用代码中的某个位置,比使用像 0xAF1C002345B7901E 这样的数字地址要方便得多。因此,汇编语言的低级控制结构在源代码中大量使用标签(参见第 2.10 节“控制转移指令”中的第 74 页)。

你可以对代码标签执行三种操作:通过条件跳转或无条件跳转指令将控制转移到标签、通过 bl 指令调用标签、以及获取标签的地址。最后一种操作在你想要稍后在程序中间接地将控制转移到该地址时非常有用。

以下代码序列演示了如何使用 lea 宏在程序中获取标签的地址:

stmtLbl:
   .
   .
   .
  lea x0, stmtLbl
   .
   .
   .
stmtLbl2:

由于地址是 64 位的,通常会使用 lea 指令将地址加载到 64 位通用寄存器中。有关如何在程序中获取标签地址的更多信息,请参见第 7.5 节“获取程序中符号的地址”中的第 364 页。

7.2 使用语句标签初始化数组

Gas 允许你使用语句标签的地址来初始化双字对象。列表 7-1 中的代码片段演示了如何做到这一点。

// Listing7-1.S
//
// Initializing qword values with the
// addresses of statement labels

#include "aoaa.inc"

            .data
            .align   3    // Align on dword boundary.
lblsInProc: .dword   globalLbl1, globalLbl2  // From procWLabels

            .code

// procWLabels
//
// Just a procedure containing private (lexically scoped)
// and global symbols. This really isn't an executable
// procedure.

             proc    procWLabels

globalLbl1:  b.al    globalLbl2
globalLbl2:
             ret
             endp    procWLabels

             .pool
             .align  3   // dword align
dataInCode:  .dword  globalLbl2, globalLbl1

你可能记得,.text 节中的指针不能引用该节之外的对象;然而,其他节(例如.data 节)中的指针是可以引用.text 节中的符号的。

由于 ARM 上的地址是 64 位的,通常会使用.dword 指令,如前面的示例所示,通过语句标签的地址来初始化数据对象。

7.3 无条件控制转移

b.al(分支)指令无条件地将控制转移到程序中的另一点。该指令有三种形式:两种基于 PC 的相对分支和一个间接跳转。这些指令的形式如下:

b    `label`   // Range is ±128MB.
b.al `label`   // Range is ±1MB.
br   `reg`64

前两条指令是PC 相对分支,你在之前的多个示例程序中已经见过。对于 PC 相对分支,通常通过使用语句标签来指定目标地址。该标签出现在可执行机器指令的同一行,或单独出现在其前一行。直接跳转完全等同于高级语言中的goto语句。

这里是一个直接跳转的例子,它将控制转移到程序中其他地方的标签:

 `statements`
           b laterInPgm   // Or b.al laterInPgm
               .
               .
               .
laterInPgm:
           `statements`

与高级语言不同,通常你的老师会禁止你使用goto语句,你会发现,在汇编语言中使用b/b.al指令是必不可少的。

7.4 寄存器间接跳转

之前给出的br reg64分支指令的第三种形式是寄存器间接跳转指令,它将控制转移到指定 64 位通用寄存器中地址所指向的指令。要使用br指令,必须在执行br之前,将一个 64 位寄存器加载为机器指令的地址。当多个路径将不同地址加载到寄存器中并汇聚到同一br指令时,控制将转移到由到该点为止的路径确定的适当位置。

清单 7-2 读取用户输入的字符字符串,这些字符串包含一个整数值。它使用strtol()将该字符串转换为二进制整数值。这个 C 语言标准库函数在报告错误方面做得不够好,因此这个程序测试返回结果以验证输入是否正确,并使用寄存器间接跳转根据结果将控制转移到不同的代码路径。

清单 7-2 的第一部分包含常量、变量、外部声明和(通常的)getTitle()函数。

// Listing7-2.S
//
// Demonstrate indirect jumps to
// control flow through a program.

#include    "aoaa.inc"

maxLen      =       256
EINVAL      =       22     // "Magic" C stdlib constant, invalid argument
ERANGE      =       34     // Value out of range

            .data
buffer:     .fill   256, 0 // Input buffer

            .text
            .pool
ttlStr:     wastr   "Listing 7-2"

fmtStrA:    wastr   "value=%d, error=%d\n"

fmtStr1:    .ascii  "Enter an integer value between "
            wastr   "1 and 10 (0 to quit): "

badInpStr:  .ascii  "There was an error in readLine "
            wastr   "(ctrl-D pressed?)\n"

invalidStr: wastr   "The input string was not a proper number\n"

rangeStr:   .ascii  "The input value was outside the "
            wastr   "range 1-10\n"

unknownStr: .ascii  "The was a problem with strToInt "
            wastr   "(unknown error)\n"

goodStr:    wastr   "The input value was %d\n"

fmtStr:     wastr   "result:%d, errno:%d\n"

// getTitle
//
// Return pointer to program title
// to the C++ code.

        proc    getTitle, public
        lea     x0, ttlStr
        ret
        endp    getTitle

清单 7-2 的下一部分是strToInt函数,它是 C 语言标准库strtol()函数的封装,能够更全面地处理用户输入的错误。请参见函数的返回值注释:

// Listing7-2.S (cont.)
//
// strToInt
//
// Converts a string to an integer, checking for errors
//
// Argument:
//    X0-   Pointer to string containing (only) decimal
//              digits to convert to an integer
//
// Returns:
//    X0-   Integer value if conversion was successful.
//    X1-   Conversion state. One of the following:
//          0- Conversion successful
//          1- Illegal characters at the beginning of the
//                 string (or empty string)
//          2- Illegal characters at the end of the string
//          3- Value too large for 32-bit signed integer

            proc    strToInt

            locals  sti
            dword   sti.saveX19
            dword   sti.endPtr
            word    sti.value
            byte    sti.stackSpace, 64
 endl    sti

            enter   sti.size

            mov     x19, x0         // Save, so you can test later.

// X0 already contains string parameter for strtol,
// X1 needs the address of the string to convert, and
// X2 needs the base of the conversion (10).

          ❶ add     x1, fp, #sti.endPtr
            mov     x2, #10             // Decimal conversion
            bl      strtol

// On return:
//
//    X0-    Contains converted value, if successful
//    endPtr-Pointer to 1 position beyond last char in string
//
// If strtol returns with endPtr == strToConv, then there were no
// legal digits at the beginning of the string.

            mov     x1, #1                // Assume bad conversion.
            ldr     x2, [fp, #sti.endPtr] // Is startPtr = endPtr?
            cmp     x19, x2
            beq     returnValue

// If endPtr is not pointing at a 0 byte, you have
// junk at the end of the string.

            mov     x1, #2        // Assume junk at end.
            ldrb    w3, [x2]      // Byte at endPtr.
            cmp     x3, #0        // Is it zero?
            bne     returnValue   // Return error if not 0.

// If the return result is 0x7fff_ffff or 0x8000_0000
// (max long and min long, respectively), and the C
// global _errno variable contains ERANGE, you have
// a range error.

            str     w0, [fp, #sti.value] // Get C errno value.
          ❷ getErrno                     // Magic macro
            mov     x2, x0
            ldr     w0, [fp, #sti.value]

            mov     x1, 0         // Assume good input.
            cmp     w2, #ERANGE   // errno = out of range?
            bne     returnValue
            mov     x1, #3        // Assume out of range.

            mov     x2, 0xffff
            movk    x2, 0x7fff, lsl #16

            cmp     w0, w2
            beq     returnValue

 mvn     w2, w2        // W2 = 0x8000_0000
            cmp     w0, w2
            beq     returnValue

// If you get to this point, it's a good number.

            mov     x0, #0

returnValue:
            leave
            endp    strToInt

strtol() ❶ 函数期望一个指向字符串结束指针变量的指针。strToInt过程在激活记录中为此指针预留了空间。此代码计算该指针变量的地址,并将其传递给strtol()函数。

获取 C 语言的errno变量 ❷ 在 macOS 和 Linux(或者更可能是 Clang 与 GCC)中处理方式不同。getErrno宏在aoaa.inc包含文件中生成适合这两个系统的代码。它将errno返回到 X0 寄存器。

清单 7-2 的最后部分是主程序,也是代码中最有趣的部分,因为它演示了如何调用strToInt函数:

// Listing7-2.S (cont.)
//
// Here's the asmMain procedure:

            proc    asmMain, public

            locals  am
            dword   am.saveX19              // Nonvolatile
            byte    am.stackSpace, 64
            endl    am

            enter   am.size
            str     x19, [fp, #am.saveX19]  // Must preserve X19.

// Prompt the user to enter a value
// from 1 to 10:

repeatPgm:  lea     x0, fmtStr1
            bl      printf

// Get user input:

            lea     x0, buffer
            mov     x1, #maxLen
            bl      readLine

            lea     x19, badInput // Initialize state machine.
          ❶ ands    w0, w0, w0    // X0 is -1 on bad input.
 bmi     hadError       // Only neg value readLine returns.

// Call strtoint to convert string to an integer and
// check for errors:

            lea     x0, buffer     // Ptr to string to convert
            bl      strToInt
            lea     x19, invalid
            cmp     w1, #1
            beq     hadError
            cmp     w1, #2
            beq     hadError

            lea     x19, range
            cmp     w1, #3
            beq     hadError

            lea     x19, unknown
            cmp     w1, #0
            bne     hadError

// At this point, input is valid and is sitting in X0.
//
// First, check to see if the user entered 0 (to quit
// the program):

          ❷ ands    x0, x0, x0     // Test for zero.
            beq     allDone

// However, we need to verify that the number is in the
// range 1-10:

            lea     x19, range
            cmp     x0, #1
            blt     hadError
            cmp     x0, #10
            bgt     hadError

// Pretend a bunch of work happens here dealing with the
// input number:

            lea     x19, goodInput

// The different code streams all merge together here to
// execute some common code (for brevity, we'll pretend that happens;
// no such code exists here):

hadError:

// At the end of the common code (which mustn't mess with
// X19), separate into five code streams based
// on the pointer value in X19:

          ❸ br      x19

// Transfer here if readLine returned an error:

badInput:   lea     x0, badInpStr
            bl      printf
            b.al    allDone

// Transfer here if there was a nondigit character
// in the string:

invalid:    lea     x0, invalidStr
            bl      printf
            b.al    repeatPgm

// Transfer here if the input value was out of range:

range:      lea     x0, rangeStr
            bl      printf
            b.al    repeatPgm

// Shouldn't ever get here. Happens if strToInt returns
// a value outside the range 0-3:

unknown:    lea     x0, unknownStr
            bl      printf
            b.al    repeatPgm

// Transfer down here on a good user input:

goodInput:  mov     w1, w0
            lea     x0, goodStr
            mstr    w1, [sp]
            bl      printf
            b.al    repeatPgm

// Branch here when the user selects "quit program" by
// entering the value 0:

allDone:    ldr     x19, [fp, #am.saveX19] // Must restore before returning.
            leave

            endp    asmMain

主函数根据 strToInt 返回结果加载 X19 寄存器,指定要执行的代码的地址。strToInt 函数返回以下几种状态之一(请参阅前面的代码注释以了解解释):

  • 有效输入

  • 字符串开头的非法字符

  • 字符串末尾的非法字符

  • 范围错误

程序接着根据 X19 中保存的值转移控制到 asmMain 的不同部分,X19 指定了 strToInt 返回结果的类型。

readline 函数返回 –1 ❶ 如果读取用户输入的文本行时发生错误,这通常发生在检测到文件结尾时。这是 readline 唯一返回的负值,因此这段代码并不直接检查 –1,而是检查 readline 是否返回了负值。这个检查有点巧妙,但它是一种标准技巧;任何时候将一个值与自己进行 AND 运算,都会得到原始值。在这个例子中,代码使用 ands 指令,如果值为 0,Z 标志会被设置,如果数字为负数,N 标志会被设置 ❷。因此,之后测试 N 标志可以检查错误情况。注意,cmp x0, #0 指令也能达到同样的目的。

再次说明,这段代码使用 ands 指令 ❷ 将结果与 0 进行比较。这一次,它实际上是在检查值是否为 0(通过 Z 标志),并紧接着使用 beq 指令进行检查。这就是列表 7-2 中的程序使用 br(通过寄存器间接分支)指令实现逻辑 ❸ 的地方。

下面是列表 7-2 的构建命令和示例运行:

$ ./build Listing7-2
$ ./Listing7-2
Calling Listing7-2:
Enter an integer value between 1 and 10 (0 to quit): a123
The input string was not a proper number
Enter an integer value between 1 and 10 (0 to quit): 123a
The input string was not a proper number
Enter an integer value between 1 and 10 (0 to quit): 1234567890123
The input value was outside the range 1-10
Enter an integer value between 1 and 10 (0 to quit): -1
The input value was outside the range 1-10
Enter an integer value between 1 and 10 (0 to quit): 11
The input value was outside the range 1-10
Enter an integer value between 1 and 10 (0 to quit): 5
The input value was 5
Enter an integer value between 1 and 10 (0 to quit): 0
Listing7-2 terminated

这个示例运行展示了几种错误输入,包括非数字输入、超出范围的值、合法值,以及输入 0 来退出程序。

7.5 获取程序中符号的地址

列表 7-2 计算了 .text 段中各种符号的地址,以便将这些地址加载到寄存器中以备后用。在汇编语言程序中,获取程序中符号的运行时地址是一项常见操作,因为这就是通过寄存器间接访问数据(和代码)的方法。

本章涵盖了控制结构,本节讨论了如何获取程序中语句标签的地址。本节中的很多信息是本书前面章节的复习材料,但我把它们汇总在这里以供参考,并扩展了讨论。

7.5.1 重新审视 lea 宏

列表 7-2 使用 lea 宏将 64 位寄存器初始化为一个通过 br 指令跳转到的位置的地址。这个宏在本书中一直是获取符号地址的首选方法。然而,请记住,lea 是一个宏,并且

lea x0, symbol

转换成这样:

// Under macOS:

    adrp x0, symbol@PAGE
    add  x0, x0, symbol@PAGEOFF

// Under Linux:

    adrp x0, symbol
    add  x0, x0, :lo12:symbol

这两条指令序列允许 lea 宏在 ±4GB 范围内计算 PC 相对符号的地址。adr 指令也可以计算符号的地址,但它只支持 ±1MB 范围(请参见第 1.8.1 节,“ldr、str、adr 和 adrp”,见 第 23 页)。

当获取 .text 段中附近语句标签的地址时,使用 adr 指令会更加高效:

adr x0, symbol

唯一的失败情况是,如果你的 .text 段非常大,符号距离 adr 指令超过 1MB。使用 lea 宏的主要原因是为了获取位于不同段中的符号地址(特别是在 macOS 上,PIE/ASLR 策略可能会将该段定位在距离 ±1MB 以上的位置)。

如果你想计算的符号/内存位置的地址距离当前指令超过 ±4GB,你将不得不使用以下章节中某种方法来获取它的地址。

7.5.2 静态计算符号的地址

由于内存地址是 64 位的,并且 .dword 指令允许你用 64 位值初始化一个 dword 对象,那么是不是可以用程序中另一个符号的 64 位地址来初始化这样一个对象呢?答案取决于你运行的操作系统。

在 Linux 下,即使运行 PIE 代码,执行以下操作是完全合法的:

varPtr:  .dword `variable`

其中变量是出现在.data、.bss、.rodata 或 .text 段中的符号名称。当 Linux 将可执行程序加载到内存时,它会自动将此 dword 内存位置修补为该符号在内存中的地址(无论 Linux 将其加载到哪里)。根据段的不同,你可能可以通过使用以下指令,假设该符号在 ldr 指令的 PC 相对范围内,直接将此位置的内容加载到 X0 寄存器中:

ldr x0, varPtr

不幸的是,这种方案可能在 macOS 上不可行,因为在 macOS 中,你不允许在 .text 段中使用绝对地址。如果你将 varPtr 移到 .data 段,macOS 会接受指针初始化,但会因非法的绝对地址拒绝 ldr 指令。当然,你可以使用 lea 宏将 varPtr 的地址加载到 X0,然后通过 [X0] 寻址模式获取变量的地址;然而,在那时,你完全可以直接使用 lea 指令将变量的地址加载到 X0。无论如何,你又回到了 lea 宏的 ±4GB 限制。

你可以通过使用相对地址而不是绝对地址来绕过 macOS 的绝对地址限制。相对地址只是内存中某个固定点的偏移量(例如,PC 相对地址是相对于存储在 PC 寄存器中的地址的偏移量)。你可以使用以下语句创建一个自相关的 64 位指针:

varPtr:  .dword `variable`-.  // "." is same as "varPtr" here.

这将初始化该 64 位内存位置,存储从 varPtr 对象到目标内存位置(变量)的距离(以字节为单位)。这被称为自相对指针,因为偏移量是相对于指针变量本身的。事实证明,macOS 的汇编器对于这个地址表达式(即使在.text 段中)也能正常工作,因为它不是一个绝对地址。

当然,你不能简单地将这 64 位加载到寄存器中并访问它们所指向的内存位置。该值是一个偏移量,而不是一个地址。然而,如果你将 varPtr 的地址加到它的内容上,你就能得到变量的地址,具体如下代码所示:

adr x0, varPtr   // Assume varPtr is in .text and nearby.
ldr x1, varPtr   // Get varPtr address and contents, then
add x0, x0, x1   // add them together for `variable`'s address.

这一序列解决了 macOS 下地址的问题,并且在 Linux 下也能很好地工作。因为这一序列在两个操作系统下都能工作,本书在从内存中获取变量地址时采用了这一方案。

在 macOS 下,这一序列要求 varPtr 与指令处于同一.text 段中。否则,macOS 会抱怨 varPtr 是一个绝对地址,并拒绝此代码。由于本书假设代码在 Linux 和 macOS 下都能正常工作,因此我会将这些标签保留在.text 段中。

在 Linux 下,单条 ldr 指令也能正常工作,因此如果你只编写 Linux 代码,使用单条 ldr 指令会更高效。

7.5.3 动态计算内存对象的地址

计算非静态内存对象的地址比计算静态内存对象(如.data、.bss、.text、.rodata 等)的地址稍微复杂一些。

由于每条 ARM 机器指令的长度恰好为 32 位,因此你可以将只包含机器指令的.text 段视为一个字数组,其中每个字的值恰好是机器指令的编码。(这个视角并不是 100%准确的;如果.text 段既包含数据又包含指令,那么将.text 段视为指令数组会有一定的局限性。然而,如果你仅限于那些只包含指令的区域,一切都会正常。)

有了这一点,就可以通过使用第 4.7 节“数组”(见第 194 页)中有关数组的技巧来操作.text 段中的值。这包括索引数组和计算数组元素的有效地址等技巧。

注意

第 5.6.2 节,“按引用传递”,见第 256 页,描述了一种计算你通过 ARM 的各种寻址模式引用的对象的有效地址的过程。有关数据对象的内容,请参见该讨论。

请看下面这段从列表 7-2 中剪切出来的任意指令序列:

goodInput:  mov     w1, w0
            lea     x0, goodStr
            mstr    w1, [sp]
            bl      printf
            b.al    repeatPgm

标签 goodInput 是包含这短序列五条指令的五个字的数组的基地址。当然,你可以通过使用 adr 指令(或者如果序列距离较远,则使用 lea 指令)来获取这个数组的基地址。一旦你将这个基地址存入寄存器(例如 X0),你就可以使用数组索引计算来计算数组中某个特定条目的地址:

`element_address` = `base_address` + `index` × `element_size`

element_size 值为 4,因为每条指令是 32 位的。索引 0 指定 mov 指令,索引 1 指定 adr 指令,依此类推。

给定 X1 中的索引值,你可以使用以下代码直接将控制转移到这五条指令中的一条:

adr x0, goodInput
add x0, x0, x1, lsl #2
br  x0

add 指令在将索引(X1)乘以 4 后加到基地址上。这计算出指定指令在序列中的字节地址;然后 br 将控制转移到该指令。

在许多方面,这类似于一个 switch 或 case 语句,每个指令与序列中的一个唯一案例相关联。本章将在 7.6.7 节“switch...case 语句”中讨论此类控制结构,见第 389 页。在此之前,只需知道,你可以通过正常的有效地址计算动态计算这个序列中某条指令的地址。

7.5.4 使用外壳

在极少数情况下,如果你需要跳转到超出条件分支指令范围的位置,你可以使用如下指令序列:

 b`cc`opposite  skipJmp
         lea       x16, destLbl
 br        x16
skipJmp:

其中 bccopposite 是你想要采取的分支的对立分支。这个对立分支跳过将控制转移到目标位置的代码。这为你提供了 lea 宏的 4GB 范围,如果你是跳转到程序中的代码,这个范围应该足够了。对立的条件分支将控制转移到代码中的正常顺序执行点(如果条件为假,你通常会跳转到这个点)。如果条件为真,控制将转移到一个通过 64 位指针跳转到原始目标位置的内存间接跳转。

这个序列被称为外壳(或蹦床),因为程序跳到这一点以便进一步执行——就像跳到蹦床上让你越跳越高一样。外壳对于使用 PC 相对寻址模式的调用和无条件跳转指令非常有用(因此其范围限制在当前指令周围的±1MB)。你很少会使用外壳跳转到程序中的另一个位置,因为你不太可能编写如此大的汇编语言程序。

请注意在此示例中使用了 X16 寄存器。ARM ABI 保留了 X16 和 X17 寄存器用于动态链接和外层使用。您可以自由地将这两个寄存器作为易失性寄存器使用,前提是执行分支指令时,它们的内容可能会发生变化(无论何种类型的分支指令,通常是 bl 指令)。编译器和链接器通常会修改超出范围的分支指令,将代码转移到一个附近的外层,再由该外层转移控制到实际目的地。在创建您自己的外层时,使用这些寄存器作为临时寄存器是合理的做法。

分支到此范围外的代码通常意味着您将控制转移到共享(或动态链接)库中的一个函数。有关此类库的详细信息,请参阅操作系统的相关文档。

表 7-1 列出了对立条件;有关 aoaa.inc 中可用的对立分支宏,请参见 表 2-11 和 第 82 页。

表 7-1:对立条件

分支条件 对立
eq ne
ne eq
hi ls
hs lo
lo hs
ls hi
gt le
ge lt
lt ge
le gt
cs cc
cc cs
vs vc
vc vs
mi pl
pl mi

如果目标位置超出了 lea 宏的±4GB 范围,则需要创建一个 4 字节指针(偏移量)指向实际位置,并使用如下代码:

 adr     x16, destPtr
    ldr     x17, destPtr
    add     x16, x16, x17
    br      x16
destPtr:
    .dword  destination-.  // Same as "destination-destPtr"

这一特定的序列足够有用,以至于 aoaa.inc 包含文件提供了一个宏来扩展它:

goto destination

如果需要调用一个超过±4GB 范围的过程,您可以发出类似的代码:

 adr     x16, destPtr
    ldr     x17, destPtr
    add     x16, x16, x17
    blr     x16
    b.al    skipAdrs
destPtr:
    .dword  destination-.
skipAdrs:

然而,做法更简单的是:

 bl      veneer
     .
     .
     .
veneer:
    goto `destination`

通过讨论外层代码后,下一节将讨论如何在汇编语言中实现类似于高级语言(HLL)的控制结构。

7.6 在汇编语言中实现常见控制结构

本节将向您展示如何使用纯汇编语言实现类似于高级语言(HLL)的控制结构,如决策、循环和其他控制结构。最后,将展示一些用于创建常见循环的 ARM 指令。

在接下来的许多示例中,本章假设各种变量是激活记录中的局部变量(通过 FP 寄存器索引)或通过 SB(X28)寄存器索引的静态/全局变量。我们假定已为所有变量的标识符做出适当的结构声明,并且 FP/SB 寄存器已正确初始化以指向这些结构。

7.6.1 决策

在最基本的形式中,决策是代码中的一个分支,它根据某个条件在两条可能的执行路径之间切换。通常(但并非总是),条件指令序列是通过条件跳转指令实现的。条件指令对应于高级语言(HLL)中的以下 if...then...endif 语句:

if(`expression`) then
    `statements`
endif;

要将其转换为汇编语言,必须编写评估表达式的语句,并在结果为假时绕过这些语句。例如,如果你有如下的 C 语句:

if(aa == bb)
{
    printf("aa is equal to bb\n");
}

你可以将其转换为汇编语言,如下所示:

 ldr  w0, [fp, #aa]    // Assume aa and bb are 32-bit integers.
      ldr  w1, [fp, #bb]
      cmp  w0, w1
      bne  aNEb             // Use opposite branch to skip then
      lea  x0, aIsEqlBstr   // " aa is equal to bb\n".
      bl   printf
aNEb:

通常,条件语句可以分为三类:if 语句、switch...case 语句和间接跳转。接下来,你将学习这些程序结构,如何使用它们以及如何在汇编语言中编写它们。

7.6.2 if...then...else 序列

最常见的条件语句是 if...then...endif 和 if...then...else...endif 语句。这两个语句的形式如图 7-1 所示。

图 7-1:if...then...else...endif 和 if...then...endif 语句

if...then...endif 语句只是 if...then...else...endif 语句的特殊情况(没有 else 块)。在 ARM 汇编语言中,if...then...else...endif 语句的基本实现看起来像这样:

 `Sequence of statements to test a condition`
          b`cc` ElseCode;

    `Sequence of statements corresponding to the THEN block`

          b.al EndOfIf

ElseCode:
    `Sequence of statements corresponding to the ELSE block`

EndOfIf:

其中 bcc 代表条件分支指令(通常是条件测试的相反分支)。

例如,假设你想将 C/C++ 语句转换为汇编语言:

if(aa == bb)
    c = d;
else
    bb = bb + 1;

为此,你可以使用以下 ARM 代码:

 ldr  w0, [fp, #aa]   // aa and bb are 32-bit integers
          ldr  w1, [fp, #bb]   // in the current activation record.
          cmp  w0, w1
          bne  ElseBlk         // Use opposite branch to goto else.
          ldr  w0, [sb, #d]    // Assume c and d are 32-bit static
          str  w0, [sb, #c]    // variables in the static base
          b.al EndOfIf         // structure (pointed at by SB).

ElseBlk:
          ldr w0, [fp, #bb]
          add w0, w0, #1
          str w0, [fp, #bb]

EndOfIf:

对于像 (aa == bb) 这样的简单表达式,为 if...then...else...endif 语句生成正确的代码是容易的。如果表达式变得更复杂,代码的复杂性也会增加。考虑以下 C/C++ if 语句:

if(((x > y) && (z < t)) || (aa != bb))
    c = d;

要将一个复杂的 if 语句转换为如下所示的三条 if 语句序列(假设使用短路求值;详情见第 7.6.5 节,“短路与完整布尔求值”,在第 382 页):

if(aa != bb)
    c = d;
else if(x > y)
    if(z < t)
         c = d;

这个转换来自以下 C/C++ 等式:

if(`expr1` && `expr2`) `stmt`;

等同于

if(`expr1`) if(`expr2`) `stmt`;

if(expr1 || `expr2`) `stmt`;

等同于

if(`expr1`) `stmt`;
else if(`expr2`) `stmt`;

在汇编语言中,前面的 if 语句变成如下所示:

// if(((x > y) && (z < t)) || (aa != bb))
//      c = d;
//
// Assume x = W0, y = W1, z = W2, t = W3, aa = W4, bb = W5, c = W6, and d = W7
// and all variables are signed integers.

          cmp  w4, w5     // (aa != bb)?
          bne  DoIf
          cmp  w0, w1     // (x > y)?
          bngt EndOfIf    // Not greater than
          cmp  w2, w3     // (z < t)?
          bnlt EndOfIf    // Not less than
DoIf:
          mov  w6, w7     // c = d
EndOfIf:

注意使用相反分支,表明掉落是要考虑的主要条件。

汇编语言中复杂条件语句的最大问题是,在写完代码后很难弄清楚自己做了什么。高级语言表达式更容易阅读和理解,因此,良好的注释对于清晰的汇编语言实现 if...then...else...endif 语句至关重要。以下代码展示了前面示例的优雅实现:

// if(((x > y) && (z < t)) || (aa != bb))
//      c = d;
//
// Assume x = W0, y = W1, z = W2, t = W3, aa = W4, bb = W5, c = W6, 
// and d = W7.
//
// Implemented as:
//
// if (aa != bb) then goto DoIf

          cmp  w4, w5   // (aa != bb)?
          bne  DoIf

// if not (x > y) then goto EndOfIf

          cmp  w0, w1   // (x > y)?
          bngt EndOfIf  // Not greater than

// if not (z < t) then goto EndOfIf

          cmp  w2, w3   // (z < t)?
          bnlt EndOfIf  // Not less than

// true block:

DoIf:
          mov w6, w7   // c = d
EndOfIf:

每当你在汇编语言中工作时,不要忘记稍微停下来,看看是否可以用汇编语言重新思考解决方案,而不是扮演“人类 C/C++ 编译器”的角色。在处理复杂的布尔表达式时,你的第一个想法应该是:“我能否使用条件比较指令来解决这个问题?”以下示例正是这么做的:

// if(((x > y) && (z < t)) || (aa != bb))
//      c = d;
//
// Assume x = W1, y = W2, z = W3, t = W4, aa = W5, bb = W6, c = W0, and d = W7.

            cmp    w1, w2              // x > y   ? gt : ngt (C ternary ?: op)
            ccmp   w3, w4, #ccnlt, gt  // x > y   ? gt : ngt
            ccmp   w5, w6, #ccne, nlt  // nlt   ? (a != bb ? ne : nne) : ne
            csel   w0, w7, w0, ne      // if(ne) c = d

cmp 指令设置(x > y)的标志。第一条 ccmp 指令设置标志来模拟符号 ge(不小于),如果(x <= y),或者基于(z < t)的比较,如果(x > y)。在执行第一条 ccmp 指令后,如果(x > y)&&(z < t),则 N ≠ V。

在执行第二条 ccmp 指令时,如果 N ≠ V(意味着符号小于),代码会设置 NZCV 来模拟 ne,并且不再比较 aa 和 bb(因为析取操作符的左侧已经为真,无需再计算第三个括号表达式)。设置 Z = 0 意味着 csel 指令会将 d 复制到 c(基于 ne 条件)。

如果在执行第二条 ccmp 指令时 N = V,ge 条件为真,这意味着与操作结果为假,你必须测试 aa 是否不等于 bb。这将为 csel 指令设置适当的标志。列表 7-3 展示了该条件比较代码的执行。

// Listing7-3.S
//
// Demonstrate the ccmp instruction
// handling complex Boolean expressions.

#include    "aoaa.inc"

            .data

xArray:     .word   -1, 0, 1,-1, 0, 1,-1, 0, 1, 1
yArray:     .word   -1,-1,-1, 0, 0, 0, 1, 1, 1, 0
zArray:     .word   -1, 0, 1,-1, 0, 1,-1, 0, 1, 0
tArray:     .word    0, 0, 0, 1, 1, 1,-1,-1,-1, 1
aArray:     .word    0, 0, 0,-1,-1,-1, 1, 1, 1, 1
bArray:     .word   -1, 0, 1,-1, 0, 1,-1, 0, 1, 1
size        =       10

            .text
            .pool
ttlStr:     wastr   "Listing 7-3"
fmtStr1:    .ascii  "((x > y) && (z < t)) || (aa != bb)\n"
            .ascii  " x  y  z  t aa bb Result\n"
            wastr   "-- -- -- -- -- -- ------\n"
fmtStr2:    wastr   "%2d %2d %2d %2d %2d %2d   %2d\n"

// getTitle
//
// Return pointer to program title
// to the C++ code:

            proc    getTitle, public
            adr     x0, ttlStr
            ret
            endp    getTitle

/////////////////////////////////////////////////////////
//
// Here's the asmMain procedure:

            proc    asmMain, public

            locals  am
            qword   saveX1920
            qword   saveX2122
            qword   saveX2324
            dword   saveX25
            byte    stackSpace, 64
            endl    am

 enter   am.size

// Save nonvolatile registers and initialize
// them to point at xArray, yArray, zArray,
// tArray, aArray, and bArray:

            stp     x19, x20, [fp, #saveX1920]
            stp     x21, x22, [fp, #saveX2122]
            stp     x23, x24, [fp, #saveX2324]
            str     x25, [fp, #saveX25]

#define x   x19
#define y   x20
#define z   x21
#define t   x22
#define aa  x23
#define bb  x24

            lea     x, xArray
            lea     y, yArray
            lea     z, zArray
            lea     t, tArray
            lea     aa, aArray
            lea     bb, bArray

            lea     x0, fmtStr1
            bl      printf

// Loop through the array elements
// and print their values along
// with the result of
// ((x > y) && (z < t)) || (aa != bb)

            mov     x25, #0
rptLp:      ldr     w1, [x, x25, lsl #2]    // W1 = x[X25]
            ldr     w2, [y, x25, lsl #2]    // W2 = y[X25]
            ldr     w3, [z, x25, lsl #2]    // W3 = z[X25]
            ldr     w4, [t, x25, lsl #2]    // W4 = t[X25]
            ldr     w5, [aa, x25, lsl #2]   // W5 = aa[X25]
            ldr     w6, [bb, x25, lsl #2]   // W6 = bb[X25]

            cmp     w1, w2
            ccmp    w3, w4, #ccnlt, gt
            ccmp    w5, w6, #ccne, nlt
            cset    w7, ne

            lea     x0, fmtStr2
            mstr    w1, [sp]
            mstr    w2, [sp, #8]
            mstr    w3, [sp, #16]
            mstr    w4, [sp, #24]
            mstr    w5, [sp, #32]
            mstr    w6, [sp, #40]
            mstr    w7, [sp, #48]
 bl      printf
            add     x25, x25, #1
            cmp     x25, #size
            blo     rptLp

// Restore nonvolatile register values
// and return:

            ldp     x19, x20, [fp, #saveX1920]
            ldp     x21, x22, [fp, #saveX2122]
            ldp     x23, x24, [fp, #saveX2324]
            ldr     x25, [fp, #saveX25]

            leave
            endp    asmMain

以下是列表 7-3 的构建命令和示例输出:

$ ./build Listing7-3
$ ./Listing7-3
Calling Listing7-3:
((x > y) && (z < t)) || (aa != bb)
 x  y  z  t aa bb Result
-- -- -- -- -- -- ------
-1 -1 -1  0  0 -1      1
 0 -1  0  0  0  0      0
 1 -1  1  0  0  1      1
-1  0 -1  1 -1 -1      0
 0  0  0  1 -1  0      1
 1  0  1  1 -1  1      1
-1  1 -1 -1  1 -1      1
 0  1  0 -1  1  0      1
 1  1  1 -1  1  1      0
 1  0  0  1  1  1      1
Listing7-3 terminated

输出显示了给定表达式的真值表。

7.6.3 使用完整布尔评估的复杂 if 语句

许多布尔表达式涉及与(AND)或或(OR)操作。你可以通过两种方式将这样的布尔表达式转换成汇编语言:使用完整布尔评估或使用短路布尔评估。本节讨论完整布尔评估,下一节讨论短路布尔评估。

通过完整布尔评估进行转换几乎与将算术表达式转换为汇编语言相同,详见第 6.4 节“逻辑表达式”,第 312 页。然而,对于布尔评估,你不需要将结果存储在变量中;一旦表达式评估完成,你只需要检查结果是假的(0)还是正确的(1,或非零),然后执行布尔表达式指示的操作。记住,只有 ands 指令会设置零标志;没有 orrs 指令。考虑以下 if 语句及其通过完整布尔评估转换为汇编语言的例子:

//     if(((x < y) && (z > t)) || (aa != bb))
//          `Stmt1` ;
//
// Assume all variables are 32-bit integers and are local
// variables in the activation record.

          ldr  w0, [fp, #x]
          ldr  w1, [fp, #y]
          cmp  w0, w1
          cset w7, lt        // Store x < y in W7.
          ldr  w0, [fp, #z]
          ldr  w1, [fp, #t]
          cmp  w0, w1
          cset w6, gt        // Store z > t in W6.
          and  w6, w6, w7    // Put (x < y) && (z > t) into W6.
          ldr  w0, [fp, #aa]
          ldr  w1, [fp, #bb]
          cmp  w0, w1
          cset w0, ne        // Store aa != bb into W0.
          orr  w0, w0, w6    // Put (x < y) && (z > t) ||
          cmp  w0, #0        //    (aa != bb) into W0.
          beq  SkipStmt1     // Branch if result is false.

      `Code for Stmt1`

SkipStmt1:

这段代码计算一个布尔结果到 W0 寄存器,然后,在计算结束时,测试该值看它是包含真还是假。如果结果为假,这段序列会跳过与 Stmt1 相关的代码。重要的是,程序会执行每一条计算布尔结果的指令(直到 beq 指令)。

到现在你应该已经认识到,通过使用 ccmp 指令,我们可以改进这段代码:

 ldr  w0, [fp, #x]
          ldr  w1, [fp, #y]
          cmp  w0, w1
          ldr  w0, [fp, #z]
          ldr  w1, [fp, #t]
          ccmp w0, w1, #ccngt, lt
          ldr  w0, [fp, #aa]
          ldr  w1, [fp, #bb]
          ccmp w0, w1, #cceq, gt
          beq  SkipStmt1     // Branch if result is false.

      `Code for Stmt1`

SkipStmt1:

代码仍然比平常稍长,但这是因为在这个例子中使用了内存变量而不是寄存器。即使这个例子使用了 ccmp 指令,代码仍然会执行每一条指令,即使条件在一开始就变为假,且永远不会变为真。

7.6.4 短路布尔评估

如果你愿意付出更多的努力(并且你的布尔表达式不依赖副作用),你通常可以通过使用短路布尔评估将布尔表达式转换为更快速的汇编语言指令序列。这种方法试图通过只执行计算完整表达式的一部分指令,来判断一个表达式是真还是假。

考虑表达式 aa && bb。一旦你确定 aa 为假,就不需要再评估 bb,因为无论 bb 的值是什么,表达式都无法为真。如果 bb 表示一个复杂的子表达式,而不是一个单一的布尔变量,那么只评估 aa 会显得更加高效。

作为一个具体的例子,考虑子表达式((x < y) && (z > t))。一旦你确定 x 不小于 y,就无需再检查 z 是否大于 t,因为无论 z 和 t 的值如何,表达式都会为假。以下代码片段展示了如何为这个表达式实现短路布尔评估:

// if((x < y) && (z > t)) then ...

          ldr  w0, [fp, #x]
          ldr  w1, [fp, #y]
          cmp  w0, w1
          bnlt TestFails
          ldr  w0, [fp, #z]
          ldr  w1, [fp, #t]
          cmp  w0, w1
          bngt TestFails

 `Code for THEN clause of IF statement`

TestFails:

一旦代码确定 x 不小于 y,它就跳过任何进一步的测试。当然,如果 x 小于 y,程序必须测试 z 是否大于 t;如果不是,程序会跳过 then 语句块。只有当程序满足两个条件时,代码才会进入 then 语句块。

对于逻辑“或”操作,技巧是类似的。如果第一个子表达式的值为真,就无需再测试第二个操作数。无论第二个操作数的值是什么,整个表达式仍然会为真。以下例子展示了如何在使用析取(||)时应用短路评估:

// if(W0 < 'A' || W0 > 'Z')
//     then printf("Not an uppercase char");
// endif;

          cmp  w0, #'A'
          blo  ItsNotUC
          cmp  w0, #'Z'
          bnhi ItWasUC

ItsNotUC:
 `Code to process W0 if it's not an uppercase character`

ItWasUC:

由于合取和析取运算符是可交换的,如果更方便,你可以先评估左侧或右侧操作数。

请注意,有些表达式依赖于最左侧子表达式的评估方式,以确保最右侧子表达式的有效性;例如,在 C/C++中,if(x != NULL && x -> y)就是一个常见的测试。

作为本节的最后一个例子,考虑上一节中的完整布尔表达式:

// if(((x < y) && (z > t)) || (aa != bb))  `Stmt1` ;

          ldr  w0, [sb, #aa]   // Assume aa and bb are globals.
          ldr  w1, [sb, #bb]
          cmp  w0, w1
          bne  DoStmt1
          ldr  w0, [fp, #x]   // Assume x, y, z, and t
          ldr  w1, [fp, #y]   // are all locals.
          cmp  w0, w1
          bnlt SkipStmt1
          ldr  w0, [fp, #z]
          ldr  w1, [fp, #t]
          cmp  w0, w1
          bngt SkipStmt1

DoStmt1:
 `Code for Stmt1`

SkipStmt1:

这个例子中的代码首先评估 aa != bb,因为它更短且更快,然后最后评估剩余的子表达式。这是汇编语言程序员常用的技巧,用来编写更高效的代码。

当然,这假设所有的比较在真或假之间的概率是相等的。如果你能预测子表达式 aa != bb 在绝大多数情况下为假,那么最好将该条件最后测试。

7.6.5 短路与完全布尔评估

使用完全布尔评估时,表达式中每个语句都会执行;而短路布尔评估则可能不需要执行与布尔表达式相关的每个语句。正如你在前两节中看到的,基于短路评估的代码通常更简洁,且可能更快。

然而,短路布尔评估在某些情况下可能无法产生正确的结果。给定一个带有 副作用(表达式中变量的变化)的表达式,短路布尔评估会产生与完全布尔评估不同的结果。考虑以下 C/C++ 示例:

if((x == y) && (++z != 0))  `Stmt`;

使用完全布尔评估时,你可能会生成如下代码:

 ldr  w0, [fp, #x]      // See if x == y.
          ldr  w1, [fp, #y]
          cmp  w0, w1
          ldr  w2, [fp, #z]
          add  w2, w1, 1         // ++z
          str  w2, [fp, #z]
          ccmp w2, #0, #cceq, eq
          beq  SkipStmt

 `Code for Stmt`

SkipStmt:

ccmp 指令将 z 增加后的值与 0 进行比较,但仅在 x 等于 y 时执行。如果 x 不等于 y,ccmp 指令会将 Z 标志设置为 1,从而控制转移到 SkipStmt,并执行以下 beq 指令。

使用短路布尔评估时,你可能会生成如下代码:

 ldr  w0, [fp, #x]      // See if x == y.
          ldr  w1, [fp, #y]
          cmp  w0, w1
          bne  SkipStmt
          ldr  w2, [fp, #z]
          adds w2, w1, 1         // ++z -- sets Z flag if z
          str  w2, [fp, #z]      // becomes 0.
          beq  SkipStmt          // See if incremented z is 0.

 `Code for Stmt`

SkipStmt:

这两种转换之间存在一个微妙但重要的差异:如果 x 等于 y,第一种版本仍然会 递增 z,并 与 0 进行比较,然后才执行与 Stmt 相关的代码。另一方面,短路版本在发现 x 等于 y 时会跳过递增 z 的代码。因此,如果 x 等于 y,这两段代码的行为是不同的。

两种实现方式都没有错;根据具体情况,你可能希望或不希望当 x 等于 y 时,代码增加 z 的值。然而,重要的是要意识到这两种方案会产生不同的结果,因此如果此代码对 z 的影响对程序至关重要,你可以选择合适的实现。

许多程序利用短路布尔评估,并依赖程序不评估表达式的某些部分。以下 C/C++ 代码片段演示了可能最常见的需要短路布尔评估的例子:

if(pntr != NULL && *pntr == 'a')  `Stmt`;

如果 pntr 结果为 NULL,则该表达式为假,不需要评估表达式的其余部分。此语句依赖于短路布尔评估才能正确操作。如果 C/C++ 使用完全布尔评估,表达式的后半部分将尝试取消引用一个 NULL 指针,而此时 pntr 为 NULL。

考虑使用完全布尔评估翻译该语句:

// Complete Boolean evaluation:

          ldr  x0, [fp, #pntr]
          cmp  x0, #0    // Check to see if X0 is 0 (NULL is 0).
          cset w1, ne    // w1 = pntr != NULL
          ldrb w0, [x0]  // Get *pntr into W0.
          cmp  w0, #'a'
          cset w2, eq
          ands w1, w1, w2
          beq  SkipStmt

  `    Code for Stmt`

SkipStmt:

如果 pntr 包含 NULL(0),该程序将尝试通过 ldrb w0, [x0] 指令访问内存中位置为 0 的数据。在大多数操作系统中,这将导致内存访问故障(段错误)。

现在考虑短路布尔转换:

 ldr  x0, [fp, #pntr] // See if pntr contains NULL (0)
      cmp  x0, #0          // and immediately skip past Stmt
      beq  SkipStmt        // if this is the case.

 ldrb w0, [x0]        // If we get to this point, pntr
      cmp  w0, #'a'        // contains a non-NULL value, so see
      bne  SkipStmt        // if it points at the character 'a'.

 `Code for Stmt`

SkipStmt:

在这个例子中,取消引用 NULL 指针的问题不存在。如果 pntr 包含 NULL,这段代码会跳过那些尝试访问 pntr 所包含的内存地址的语句。

7.6.6 在汇编语言中高效实现 if 语句

在汇编语言中高效地编码 if 语句比仅仅选择短路求值而不是完全布尔求值需要更多的思考。为了编写能够尽可能快速执行的汇编语言代码,你必须仔细分析情况并适当生成代码。以下段落提供了一些建议,可以帮助你提升程序的性能。

7.6.6.1 了解你的数据

程序员经常错误地假设数据是随机的。实际上,数据很少是随机的,如果你了解程序常用的值的类型,你就可以编写更好的代码。为了理解如何做到这一点,考虑下面的 C/C++ 语句:

if((aa == bb) && (c < d)) ++i;

因为 C/C++ 使用短路求值,这段代码将测试 aa 是否等于 bb。如果是,它将测试 c 是否小于 d。如果你期望 aa 大多数时候等于 bb,但不期望 c 大多数时候小于 d,这段代码的执行速度将比预期的要慢。考虑以下 Gas 实现的代码:

 ldr  w0, [fp, #aa]
          ldr  w1, [fp, #bb]
          cmp  w0, w1
          bne  DontIncI

          ldr  w0, [fp, #c]
          ldr  w1, [fp, #d]
          cmp  w0, w1
          bnlt DontIncI

          ldr  w0, [sb, #i]
          add  w0, w0, #1
          str  w0, [sb, #i]

DontIncI:

如你所见,如果 aa 大多数时候等于 bb,且 c 大多数时候不小于 d,你将几乎每次都执行前八条指令,以确定表达式是假的。现在,考虑以下实现,它利用了这一知识以及 && 运算符是交换律的事实:

 ldr  w0, [fp, #c]
          ldr  w1, [fp, #d]
          cmp  w0, w1
          bnlt DontIncI

          ldr  w0, [fp, #aa]
          ldr  w1, [fp, #bb]
          cmp  w0, w1
          bne  DontIncI

          ldr  w0, [sb, #i]
          add  w0, w0, #1
          str  w0, [sb, #i]

DontIncI:

代码首先检查 c 是否小于 d。如果大多数情况下 c 不小于 d,这段代码将在典型情况下只执行三条指令后跳转到标签 DontIncI,而与之前的例子相比,它需要执行七条指令。

像这样的优化在汇编语言中比在高级语言(HLL)中更为明显,这也是汇编程序通常比高级语言程序更快的主要原因之一。关键在于理解数据的行为,以便做出明智的决策。

7.6.6.2 重排表达式

即使你的数据是随机的,或者你无法确定输入值将如何影响你的决策,重新排列表达式中的项仍然可能是有益的。一些计算的执行时间远比其他的要长。例如,计算余数比简单的 cmp 指令慢。因此,如果你有像下面这样的语句,你可能想要重新排列表达式,使 cmp 语句排在前面:

if((x % 10 = 0) && (x != y)) ++x;

直接转换为汇编代码后,这个 if 语句变成了以下形式:

 ldr   w1, [fp, #x]      // Compute x % 10.
          mov   w2, #10
          udiv  w0, w1, w2
          msub  w0, w0, w2, w1
          cmp   w0, #0
          bne   SkipIf

 ldr   w0, [fp, #x]
          ldr   w1, [fp, #y]
          cmp   w0, w1
          beq   SkipIf

          add   w0, w0, #1        // ++x
          str   w0, [fp, #x]

SkipIf:

余数计算很耗时(大约是此例中其他指令速度的三分之一)。除非余数为 0 的可能性比 x 等于 y 的可能性大三倍,否则最好先进行比较,然后再进行余数计算:

 ldr   w1, [fp, #x]      // Compute x % 10.
          ldr   w1, [fp, #y]
          cmp   w0, w1
          beq   SkipIf

          ldr   w1, [fp, #x]
          mov   w2, #10
          udiv  w0, w1, w2
          msub  w0, w0, w2, w1
          cmp   w0, #0
          bne   SkipIf

          add   w1, w1, #1        // ++x
          str   w1, [fp, #x]

SkipIf:

&& 和 || 运算符在数学意义上是可交换的,即无论先计算左侧还是右侧,逻辑结果是相同的。但在执行时,它们并不是可交换的,因为评估顺序可能导致跳过第二个子表达式的评估;特别是,如果表达式中存在副作用,这些运算符可能不具有可交换性。这个例子没有问题,因为没有副作用或可能被重新排序的 && 运算符屏蔽的异常。

7.6.6.3 解构代码

结构化代码有时比非结构化代码效率低,因为它可能引入代码重复或额外的分支,这些在非结构化代码中可能不存在。大多数时候,这是可以容忍的,因为非结构化代码难以阅读和维护;为了可维护的代码而牺牲一些性能通常是可以接受的。然而,在某些情况下,你可能需要尽可能多的性能,并可能选择牺牲代码的可读性。

在高级语言中,你通常可以通过编写结构化代码来避免问题,因为编译器会优化它,生成非结构化的机器代码。不幸的是,在汇编语言中,你得到的机器代码与所写的汇编代码完全等效。

将之前编写的结构化代码重新编写为非结构化代码以提高性能被称为解构代码。非结构化代码和解构代码的区别在于,非结构化代码一开始就是以这种方式编写的;解构代码最初是结构化代码,并被故意以非结构化的方式编写,以提高其效率。纯粹的非结构化代码通常难以阅读和维护。解构代码则不那么糟糕,因为你将代码的“非结构化”限制在那些绝对需要的部分。

解构代码的经典方法之一是使用代码移动,将代码的某些部分物理移到程序的其他地方。你将程序中不常用的代码移到不妨碍大多数时候执行的代码的地方。

代码移动可以通过两种方式提高程序的效率。首先,执行的分支比未执行的分支更昂贵(耗时)。如果你将不常用的代码移到程序的另一个位置,并在分支被执行的少数情况下跳转到它,大多数时候你将直接跳到最常执行的代码。其次,顺序的机器指令会消耗缓存存储。如果你将不常执行的语句移出正常的代码流,放到程序中一个不常加载到缓存中的部分,这将提高系统的缓存性能。

例如,考虑以下伪 C/C++ 语句:

if(`See_If_an_Error_Has_Occurred`) 
{
    ` Statements to execute if no error` 
}
else 
{
     `Error-handling statements` 
}

在正常的代码中,你不期望错误发生得很频繁。因此,你通常期望前面 if 语句中的 then 部分比 else 子句更常执行。前面的代码可能会翻译成以下汇编代码:

 cmp `See_If_an_Error_Has_Occurred`, #true 
     beq HandleTheError 

 `Statements to execute if no error` 

     b.al EndOfIf 

HandleTheError: 
    `       Error-handling statements` 
EndOfIf: 

如果表达式为假,这段代码会跳过错误处理语句,直接执行正常的语句。将程序中的控制从一个点转移到另一个点的指令(例如,b.al 指令)通常会很慢。执行一组顺序指令比在程序中到处跳转要快得多。不幸的是,前面的代码并不允许这样做。

解决这个问题的一种方法是将代码中的 else 子句移动到程序的其他地方。你可以像下面这样重写代码:

 cmp `See_If_an_Error_Has_Occurred`, #true 
     beq HandleTheError 

 `Statements to execute if no error` 

EndOfIf: 

  // At some other point in your program (typically after a b.al 
  // or ret instruction), you would insert the following code: 

HandleTheError: 
 `Error-handling statements` 
     b.al EndOfIf 

程序并没有变得更短。你从原始序列中移除的 b.al 最终会出现在 else 子句的末尾。然而,由于 else 子句很少执行,将 b.al 指令从经常执行的 then 子句移到 else 子句是一个巨大的性能提升,因为 then 子句仅使用直线代码执行。这种技巧在许多时间关键的代码段中非常有效。

7.6.6.4 计算而非分支

在 ARM 处理器中,与许多其他指令相比,分支操作是昂贵的。因此,有时执行更多的顺序指令比执行较少的涉及分支的指令要好。

例如,考虑简单的赋值 w0 = abs(w0)。不幸的是,没有 ARM 指令可以计算整数的绝对值。处理此问题的显而易见方法是使用一个指令序列,通过条件跳转跳过 neg 指令(如果 W0 为负值,neg 指令会使 W0 变为正值):

 cmp w0, #0
          bpl ItsPositive

          neg w0, w0

ItsPositive:

现在考虑以下序列,它也能完成任务:

 cmp  w0, #0
          cneg w0, w0, mi

不仅指令集更短,而且不涉及任何分支,因此运行速度更快。这展示了为什么了解指令集是很重要的!

你见过的另一个计算与分支的例子是使用 ccmp 指令处理布尔表达式中的合取与析取(见第 7.6.5 节,“短路与完全布尔运算”,在 第 382 页)。虽然它们通常比短路求值执行更多的指令,但不涉及任何分支,而且这通常意味着代码运行得更快。

有时,无法通过不使用分支来计算。对于某些类型的分支(特别是多路分支),你可以将计算与单一的分支结合起来处理复杂的操作,正如下一节所讨论的那样。

7.6.7 switch...case 语句

C/C++ 的 switch 语句采用以下形式:

 switch(`expression`) 
      {
         case `const1`: 
           `Code to execute if` 
 `expression equals const1` 

         case `const2`: 
           `Code to execute if` 
 `expression equals const2` 
           . 
           . 
           . 
         case `constn`: 
           `Code to execute if` 
 `expression equals constn` 

         default:  // Note that the default section is optional. 
           `Code to execute if expression` 
 `does not equal any of the case values` 

      }

当这条语句执行时,它会检查表达式的值与常量 const1 到 constn 的匹配情况。如果找到匹配项,相应的语句将执行。

C/C++对 switch 语句有一些限制。首先,它只允许一个整数表达式(或其底层类型可以是整数的表达式)。其次,所有 case 子句中的常量必须是唯一的。接下来的几个小节将描述 switch 语句的语义以及各种实现方式,并澄清这些限制的原因。

7.6.7.1 switch 语句的语义

大多数入门级编程教材通过将 switch...case 语句解释为一系列 if...then...elseif...else...endif 语句来介绍它们。它们可能会声称以下两段 C/C++代码是等效的:

switch(`w0)` 
{
    case 0: printf("i=0"); break; 
    case 1: printf("i=1"); break; 
    case 2: printf("i=2"); break; 
}

if(w0 == 0) 
    printf("i=0");
else if(w0 == 1) 
    printf("i=1");
else if(w0 == 2) 
    printf("i=2");

虽然语义上这两段代码是相同的,但它们的实现通常是不同的。if...then...elseif...else...endif 链条会对序列中的每个条件语句进行比较,而 switch 语句通常使用间接跳转,通过单一的计算将控制权转移到多个语句中的任何一个。

7.6.7.2 if...else 实现 switch

switch(和 if...else...elseif)语句可以用以下汇编代码编写:

// if...then...else...endif form: 

          ldr w0, [fp, #i] 
          cmp w0, #0         // Check for 0\. 
          bne Not0 

  `Code to print "i = 0"`

          b.al EndCase 

Not0: 
          cmp w0, #1 
          bne Not1 

 `Code to print "i = 1"`

          b.al EndCase 

Not1: 
          cmp w0, #2 
          bne EndCase 

  `Code to print "i = 2"`

EndCase: 

这段代码需要更长的时间来确定最后一个 case 应该执行,而不是判断第一个 case 是否执行。这是因为 if...else...elseif 版本会通过 case 值进行线性查找,一个一个地检查,直到找到匹配项。

7.6.7.3 间接跳转的 switch 实现

通过使用间接跳转表(包含跳转目标地址的表),可以实现更快的 switch 语句。这种实现将 switch 表达式作为索引,查找地址表中的项;每个地址指向相应 case 的代码进行执行。以下示例演示了如何使用跳转表:

// Indirect jump version 

        ldr  w0, [fp, #i]   // Zero-extends into X0! 
      ❶ adr  x1, JmpTbl 
      ❷ ldr  x0, [x1, x0, lsl #3] 
      ❸ add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt0-JmpTbl, Stmt1-JmpTbl, Stmt2-JmpTbl 

Stmt0: 

 `Code to print "i = 0"`

        b.al EndCase 

Stmt1: 

 `Code to print "i = 1"`

         b.al EndCase 

Stmt2: 

 `Code to print "i = 2"`

EndCase: 

要使用缩放索引寻址模式,代码首先将跳转表(JmpTbl)的地址加载到 X1 ❶中。由于 JmpTbl 位于.text 段(并且离当前位置较近),代码使用 PC 相对寻址模式。

代码从 JmpTbl 中获取第i项 ❷。由于跳转表中的每一项长度为 8 字节,代码必须将索引(i,存储在 X0 中)乘以 8,这由 lsl #3 参数处理。基地址(存储在 X1 中)加上索引乘以 8 得到 JmpTbl 中相应项的地址。

由于 JmpTbl 中的项是偏移量,而不是绝对地址(请记住,macOS 不允许在.text 段中使用绝对地址),你必须通过加上跳转表的基地址 ❸来将偏移量转换为绝对地址(因为表中的每一项都是相对于基地址的偏移)。以下 br 指令将控制权转移到 switch 语句中相应的 case。

首先,switch 语句要求你创建一个指针数组,每个元素包含你代码中某个语句标签的地址;这些标签必须附加到要为每个 case 执行的指令序列上。如代码注释所示,macOS 在这里不允许使用绝对地址,因此代码使用跳转表的基地址的偏移量,这对于 Linux 也适用。在这个例子中,初始化为 Stmt0、Stmt1 和 Stmt2 标签的偏移量的 JmpTbl 数组就起到了这个作用。你必须将跳转表数组放置在一个永远不会被执行为代码的位置(比如紧接着 br 指令之后,正如这个例子中所示)。

程序将 W0 寄存器加载为 i 的值(假设 i 是 32 位无符号整数,ldr 指令将 W0 零扩展到 X0)。然后,它将这个值用作 JmpTbl 数组的索引(W1 保存 JmpTbl 数组的基地址),并将控制转移到指定位置找到的 8 字节地址。例如,如果 W0 包含 0,br x0 指令将从地址 JmpTbl+0 获取双字(W0 × 8 = 0)。由于表中的第一个双字包含 Stmt0 的偏移量,br 指令将控制转移到 Stmt0 标签后面的第一条指令。同样,如果 i(因此 W0)包含 1,那么间接 br 指令会从表中的偏移量 8 获取双字,并将控制转移到 Stmt1 标签后面的第一条指令(因为 Stmt1 的偏移量出现在表中的偏移量 8 处)。最后,如果 i(W0)包含 2,那么这段代码将控制转移到 Stmt2 标签后面的语句,因为它出现在 JmpTbl 表中的偏移量 16 处。

当你添加更多(连续的)case 时,跳转表的实现比 if...elseif 形式更高效(在空间和速度方面)。除了简单的情况,switch 语句几乎总是更快,通常差距很大。只要 case 值是连续的,switch 语句版本通常也更小。

7.6.7.4 非连续跳转表项与范围限制

如果你需要包含非连续的 case 标签,或者不能确定 switch 值是否会超出范围,会发生什么?在 C/C++的 switch 语句中,遇到这种情况时,控制将转移到 switch 语句后的第一个语句(或者,如果 switch 中有默认 case,则转移到默认 case)。

然而,在前一节的示例中并未发生这种情况。如果变量 i 的值不包含 0、1 或 2,执行之前的代码会产生未定义的结果。例如,如果在执行代码时 i 的值为 5,间接 br 指令会在 JmpTbl 中获取偏移量 40(5 × 8)处的 dword,并将控制转移到该偏移量。不幸的是,JmpTbl 并没有六个条目,因此程序会获取 JmpTbl 后面第六个双字的值,并将其作为目标偏移量,这通常会导致程序崩溃或将控制转移到一个意外的位置。

解决方法是在间接 br 指令之前放置一些指令,以验证 switch 选择值是否在合理范围内。在之前的例子中,你需要验证 i 的值是否在 0 到 2 的范围内,然后再执行 br 指令。如果 i 的值超出这个范围,程序应该直接跳转到 endcase 标签,这对应于跳转到整个 switch 语句后的第一条语句。以下代码提供了这个修改:

 ldr  w0, [fp, #i]  // Zero-extends into X0! 
        cmp  w0, #2        // Default case if i > 2 
        bhi  EndCase 
        adr  x1, JmpTbl 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt0-JmpTbl, Stmt1-JmpTbl, Stmt2-JmpTbl 

Stmt0: 

 `Code to print "i = 0"`

        b.al EndCase 

Stmt1: 

 `Code to print "i = 1"`

        b.al EndCase 

Stmt2: 

 `Code to print "i = 2"`

EndCase: 

尽管这段代码处理了选择值超出 0 到 2 范围的问题,但它仍然存在两个严重的限制:

  • 各种情况必须从值 0 开始。也就是说,最小的情况常量必须为 0。

  • 情况值必须是连续的。

解决第一个问题很容易,可以通过两步来处理。首先,你需要将选择值与上下界进行比较,然后确定该值是否有效,如下例所示:

// SWITCH statement specifying cases 5, 6, and 7: 
// WARNING: This code does *NOT* work. 
// Keep reading to find out why. 

        ldr  w0, [fp, #i]  // Zero-extends into X0! 
        cmp  w0, #5        // Verify i is in the range 
        blo  EndCase       // 5 to 7 before indirect 
        cmp  w0, #7        // branch executes. 
        bhi  EndCase 
        adr  x1, JmpTbl 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt5-JmpTbl, Stmt6-JmpTbl, Stmt7-JmpTbl 

Stmt5: 
 `Code to print "i = 5"`

        b.al EndCase 

Stmt6: 
 `Code to print "i = 6"`

        b.al EndCase 

Stmt7: 
 `Code to print "i = 7"`

EndCase: 

这段代码添加了一对额外的指令,cmp 和 blo,用于测试选择值,以确保其在 5 到 7 的范围内。如果不在该范围内,控制将跳转到 EndCase 标签;否则,控制通过间接 br 指令转移。不幸的是,正如注释所指出的,这段代码是有问题的。

考虑当变量 i 的值为 5 时会发生什么:代码将验证 5 是否在 5 到 7 的范围内,然后会获取偏移量 40(5 × 8)处的 dword,并跳转到该地址。然而,和之前一样,这会加载表格边界之外的 8 字节,并且不会将控制转移到一个定义的位置。一个解决方案是在执行 br 指令之前,从 W0 中减去最小的情况选择值,如下例所示:

// SWITCH statement specifying cases 5, 6, and 7: 

        ldr  w0, [fp, #i]  // Zero-extends into X0! 
        subs w0, w0, #5    // Subtract smallest range. 
        blo  EndCase       // Subtract sets flags same as cmp! 
 cmp  w0, #7-5      // Verify in range 5 to 7\. 
        bhi  EndCase 
        adr  x1, JmpTbl 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt5-JmpTbl, Stmt6-JmpTbl, Stmt7-JmpTbl 

Stmt5: 
 `Code to print "i = 5"`
        b.al EndCase 

Stmt6: 
 `Code to print "i = 6"`
        b.al EndCase 

Stmt7: 
 `Code to print "i = 7"`

EndCase: 

通过从 W0 中减去 5,代码强制 W0 在 br 指令之前取值为 0、1 或 2。因此,选择值为 5 会跳转到 Stmt5,选择值为 6 会将控制转移到 Stmt6,而选择值为 7 会跳转到 Stmt7。

这段代码有一个小技巧:subs 指令发挥了双重作用。它不仅将 switch 表达式的下限调整为 0,而且还作为下限与 5 的比较。记住,cmp 指令的作用和 subs 指令一样,会设置标志。因此,减去 5 等同于与 5 比较,就标志设置而言。当将 W0 中的值与 7 进行比较时,实际上应该与 2 进行比较,因为我们已经从原始索引值中减去了 5。

你可以通过另一种方式处理不以 0 开头的情况:

// SWITCH statement specifying cases 5, 6, and 7: 

        ldr  w0, [fp, #i]  // Zero-extends into X0! 
        cmp  w0, #5        // Verify the index is in 
        blo  EndCase       // the range 5 to 7\. 
        cmp  w0, #7 
        bhi  EndCase 
        adr  x1, JmpTbl - 5*8 // Base address - 40 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt5-JmpTbl, Stmt6-JmpTbl, Stmt7-JmpTbl 

Stmt5: 

 `Code to print "i = 5"`

        b.al EndCase 

Stmt6: 

 `Code to print "i = 6"`

        b.al EndCase 

Stmt7: 

 `Code to print "i = 7"`

EndCase: 

这个示例在将跳转表的基地址加载到 X1 时,从跳转表基地址减去 40(5 × 8)。索引仍然在 5 到 7 的范围内,偏移量为 40 到 56;但是,由于基地址现在指定在实际表之前的 40 字节,因此数组索引计算正确地索引到了跳转表条目。

C/C++ 的 switch 语句提供了一个 default 子句,当 case 选择值与任何 case 值不匹配时,该子句会执行。以下的 switch 语句包含了一个 default 子句:

switch(`expression`) 
{

    case 5:  printf("expression = 5"); break; 
    case 6:  printf("expression = 6"); break; 
    case 7:  printf("expression = 7"); break; 
    default: 
        printf("expression does not equal 5, 6, or 7");
}

在纯汇编语言中实现等效的 default 子句很简单:只需在代码开头的 blo 和 bhi 指令中使用不同的目标标签。以下示例实现了一个类似于前面那个的 switch 语句:

// SWITCH statement specifying cases 5, 6, and 7: 

        ldr  w0, [fp, #i]     // Zero-extends into X0! 
        cmp  w0, #5           // Verify the index is in 
        blo  DefaultCase      // the range 5 to 7\. 
        cmp  w0, #7 
 bhi  DefaultCase 
        adr  x1, JmpTbl - 5 * 8 // Base address - 40 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt5-JmpTbl, Stmt6-JmpTbl, Stmt7-JmpTbl 

Stmt5: 

 `Code to print "i = 5"`

        b.al EndCase 

Stmt6: 

 `Code to print "i = 6"`

        b.al EndCase 

Stmt7: 

 `Code to print "i = 7"`

        b.al EndCase 

DefaultCase: 

  `Code to print` `"expression does not equal 5, 6, or 7"`

EndCase: 

前面提到的第二个限制,即 case 值需要是连续的,可以通过在跳转表中插入额外条目来轻松处理。考虑以下 C/C++ 的 switch 语句:

switch(i) 
{
    case 1:  printf("i = 1"); break; 
    case 2:  printf("i = 2"); break; 
    case 4:  printf("i = 4"); break; 
    case 8:  printf("i = 8"); break; 
    default: 
        printf("i is not 1, 2, 4, or 8");
}

最小的 switch 值是 1,最大值是 8。因此,在间接 br 指令之前的代码需要将 i 的值与 1 和 8 进行比较。如果值在 1 到 8 之间,仍然有可能 i 不包含一个合法的 case 选择值。然而,由于 br 指令是通过双字表来索引的,因此该表必须有八个双字条目。

为了处理 1 到 8 之间不作为 case 选择值的情况,只需将 default 子句的语句标签(或在没有 default 子句时,指定 switch 结束后第一条指令的标签)放入每个没有相应 case 子句的跳转表条目中。以下代码演示了这一技术:

// SWITCH statement specifying cases 1, 2, 4, and 8: 

        ldr  w0, [fp, #i]     // Zero-extends into X0! 
        cmp  w0, #1           // Verify the index is in 
        blo  DefaultCase      // the range 1 to 8\. 
        cmp  w0, #8 
        bhi  DefaultCase 
        adr  x1, JmpTbl - 1 * 8 // Base address - 8 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 

JmpTbl: .dword Stmt1-JmpTbl 
        .dword Stmt2-JmpTbl 
        .dword DefaultCase-JmpTbl // Case 3 
        .dword Stmt4-JmpTbl 
        .dword DefaultCase-JmpTbl // Case 5 
        .dword DefaultCase-JmpTbl // Case 6 
        .dword DefaultCase-JmpTbl // Case 7 
        .dword Stmt8-JmpTbl 

Stmt1: 

 `Code to print "i = 1"`

        b.al EndCase 

Stmt2: 

 `Code to print "i = 2"`

        b.al EndCase 

Stmt4: 

 `Code to print "i = 4"`

        b.al EndCase 

Stmt8: 

 `Code to print "i = 8"`

        b.al EndCase 

DefaultCase: 

 `Code to print` `"expression does not equal 1, 2, 4, or 8"`

EndCase: 

这段代码使用 cmp 指令确保 switch 值在 1 到 8 的范围内,并在符合条件时将控制转移到 DefaultCase 标签。

7.6.7.5 稀疏跳转表

当前的 switch 语句实现存在一个问题。如果 case 值包含非连续的、间隔很大的条目,跳转表可能变得异常庞大。以下的 switch 语句将生成一个极其庞大的代码文件:

switch(i) 
{
    case 1:        `Stmt1` ; 
    case 100:      `Stmt2` ; 
    case 1000:     `Stmt3` ; 
    case 10000:    `Stmt4` ; 
    default:       `Stmt5` ; 

} 

在这种情况下,如果你使用一系列 if 语句来实现 switch 语句,而不是使用间接跳转语句,那么你的程序会变得更小。然而,跳转表的大小通常不会影响程序的执行速度。无论跳转表包含 2 个条目还是 2000 个条目,switch 语句都将在恒定的时间内执行多重分支。if 语句的实现则需要随着 case 语句中每个标签的增加而线性增长所需的时间。

使用汇编语言而非像 Swift 或 C/C++ 这样的高级语言的最大优势之一是,你可以选择像 switch 语句这样的语句的实际实现方式。在某些情况下,你可以将 switch 语句实现为一系列 if...then...elseif 语句,也可以将其实现为跳转表,或者你可以使用两者的混合体。以下代码示例演示了将 if...then...elseif 和跳转表实现结合在一起,以实现相同的控制结构:

switch(i) 
{
    case 0:    `Stmt0` ; 
    case 1:    `Stmt1` ; 
    case 2:    `Stmt2` ; 
    case 100:  `Stmt3` ; 
    default:   `Stmt4` ; 

} 

这段代码可能会变成以下内容:

 ldr  w0, [fp, #i] 
        cmp  w0, #100         // Special case 100 
        beq  DoStmt3 
        cmp  w0, #2 
        bhi  DefaultCase 
        adr  x1, JmpTbl 
        ldr  x0, [x1, x0, lsl #3] 
        add  x0, x0, x1 
        br   x0 
         . 
         . 
         . 

有些 switch 语句包含稀疏的 case,但这些 case 通常会被分组成连续的簇。考虑下面这个 C/C++ switch 语句:

switch(`expression`) 
{
    case 0: 

  `Code for case 0` 

        break; 

    case 1: 

  `Code for case 1` 

        break; 

    case 2: 

  `Code for case 2` 

        break; 

    case 10: 

  `Code for case 10` 

        break; 

    case 11: 

 `Code for case 11` 

        break; 

    case 100: 

 `Code for case 100` 

        break; 

    case 101: 

  `Code for case 101` 

        break; 

    case 103: 

  `Code for case 103` 

        break; 

    case 1000: 

  `Code for case 1000` 

        break; 

    case 1001: 

  `Code for case 1001` 

        break; 

    case 1003: 

  `Code for case 1003` 

        break; 

    default: 

 `Code for default case` 

        break; 
} // End switch. 

你可以将由多个相邻(几乎)连续的 case 组成的 switch 语句转换为汇编语言代码,方法是为每个连续的 group 实现一个跳转表,然后使用比较指令来确定执行哪个跳转表指令序列。下面是前面 C/C++ 代码的一种可能实现方式:

// Assume expression has been computed and is sitting in X0 
// at this point ... 

         cmp   x0, #100 
         blo   try0_11 
         cmp   x0, #103 
 bhi   try1000_1003 
         adr   x1, jt100 - 100*8 
         ldr   x0, [x1, x0, lsl #3] 
         add   x0, x0, x1 
         br    x0 

jt100:   .dword case100-jt100, case101-jt100 
         .dword default-jt100, case103-jt100 

try0_11: cmp   x0, #11 // Handle cases 0-11 here. 
         bhi   default 
         adr   x1, jt0_11 
         ldr   x0, [x1, x0, lsl #3] 
         add   x0, x0, x1 
         br    x0 

jt0_11:  .dword case0-jt0_11, case1-jt0_11, case2-jt0_11 
         .dword default-jt0_11, default-jt0_11 
         .dword default-jt0_11, default-jt0_11 
         .dword default-jt0_11, default-jt0_11 
         .dword default-jt0_11, case10-jt0_11, case11-jt0_11 

try1000_1003: 
         cmp   x0, #1000 
         blo   default 
         cmp   x0, #1003 
         bhi   default 
         adr   x1, jt1000 - 1000*8 
         ldr   x0, [x1, x0, lsl #3] 
         add   x0, x0, x1 
         br    x0 
jt1000:  .dword case1000-jt1000, case1001-jt1000 
         .dword default-jt1000, case1003-jt1000 
           . 
           . 
           . 
 `Code for the actual cases here` 

这段代码将 0 到 2 组和 10 到 11 组合并为一个单独的组(需要额外的七个跳转表条目),以便节省不必编写额外的跳转表序列。对于这样简单的一组 case,使用比较与分支序列更为方便,但我简化了这个示例以演示多个跳转表。

7.6.7.6 其他 switch 语句的替代方案

如果 case 太稀疏,只能逐个比较表达式的值会发生什么情况?在这种情况下,代码不一定注定要被转换为等同于 if...elseif...else...endif 的语句。然而,在考虑其他替代方案之前,请记住,并非所有的 if...elseif...else...endif 语句都是一样的。回顾前一节中的最后一个示例(稀疏的 switch 语句)。一种简单的实现可能是这样的:

if(unsignedExpression <= 11) 
{
  `Switch for 0 to 11.` 
}
else if(unsignedExpression >= 100 && unsignedExpression <= 103) 
{
  `Switch for 100 to 103.` 
}
else if(unsignedExpression >= 1000 && unsignedExpression <= 1003) 
{
  `Switch for 1000 to 1003.` 
}
else 
{
 `Code for default case` 
}

相反,前一种实现首先对值 100 进行测试,并根据比较结果分支到小于(情况 0 到 11)或大于(情况 1000 到 1001),有效地创建了一个小的二分查找,减少了比较的次数。虽然在高级语言代码中很难看到节省的地方,但在汇编代码中,你可以计算出在最佳和最差情况下执行的指令数量,并看到相较于标准的线性搜索方法(只是按顺序比较 switch 语句中出现的情况值),有所改进。(当然,如果你在稀疏的 switch 语句中有许多分组,二分查找平均会比线性查找快得多。)

如果你的情况过于稀疏(根本没有有意义的分组),比如在第 7.6.7.5 节《稀疏跳转表》中的 1, 10, 100, 1,000, 10,000 的示例(见第 399 页),你就无法合理地通过跳转表来实现 switch 语句。与其转而使用直接的线性搜索(可能很慢),更好的解决方案是对你的情况进行排序,并通过二分查找来测试它们。

使用二分查找,你首先将表达式值与中间的情况值进行比较。如果小于中间值,你就对值列表的前半部分继续搜索;如果大于中间值,你就对值列表的后半部分继续搜索;如果相等,显然你就进入代码处理该测试。以下代码展示了 1, 10, 100, ... 示例的二分查找版本:

// Assume expression has been calculated into X0\. 

        cmp x0, #100 
        blo try1_10 
        bhi try1000_10000 

 `Code to handle case 100` 

        b.al AllDone 

try1_10: 
        cmp x0, #1 
        beq case1 
        cmp x0, #10 
        bne defaultCase 

 `Code to handle case 10` 

        b.al AllDone 
case1: 
  `  Code to handle case 1` 

        b.al AllDone 

try1000_10000: 
        cmp x0, #1000 
        beq case1000 
        mov x1, #10000   // cmp can't handle 10000\. 
        cmp x0, x1 
        bne defaultCase 

 `Code to handle case 10,000` 

        b.al AllDone 

case1000: 

 `Code to handle case 1,000` 

        b.al AllDone 

defaultCase: 

 `Code to handle defaultCase` 

AllDone: 

本节中介绍的技术有许多可能的替代方案。例如,一个常见的解决方案是创建一个表,表中包含一组记录,每条记录是一个包含情况值和跳转地址的二元组。与其有一长串比较指令,不如使用一个短循环来遍历所有表元素,搜索情况值,并在匹配时将控制转移到相应的跳转地址。这个方案比本节中的其他技术要慢,但比传统的 if...elseif...else...endif 实现要简洁得多。稍加努力,如果表是有序的,你也可以使用二分查找。

7.6.7.7 跳转表大小减少

到目前为止,所有的 switch 语句示例都使用了双字数组作为跳转表。使用 64 位偏移量,这些跳转表可以将控制转移到 ARM 地址空间中的任何位置。实际上,这个范围几乎从不需要。大多数偏移量将是相对较小的数字(通常小于±128,或±32,767)。这意味着跳转表项的高位可能全是 0 或全是 1(如果偏移量为负)。通过稍微修改通过跳转表传递控制的指令,将表的大小减半是很容易的:

 adr  x1, JmpTbl 
        ldr  w0, [x1, x0, lsl #2]   // X4 for 32-bit entries 
        add  x0, x1, w0, sxtw       // Sign-extend W0 to 64 bits. 
        br   x0 

JmpTbl: .word Stmt1-JmpTbl, ... 

这个示例对本章中的其他示例做了三处修改:

  • 扩展索引寻址模式(ldr 指令)将索引(在 X0 中)按 4 缩放,而不是按 8 缩放(因为我们访问的是字数组而不是双字数组)。

  • add 指令在将值与 X1 相加之前,对 W0 进行符号扩展至 64 位。

  • 跳转表包含字条目而不是双字条目。

这个修改将 case 标签的范围限制在跳转表周围的 ±2GB 范围内,而不是完整的 64 位地址空间——对于大多数程序来说,这几乎不算限制。作为交换,跳转表的大小现在只有原来的一半。

在你产生将表项大小减小为 16 位(从而获得 ±32K 范围)的狡猾想法之前,请注意,macOS 和 Linux 的目标代码格式——分别为 Mach-O 和可执行链接格式(ELF)——都不支持 16 位可重定位偏移量;32 位偏移量是你能做的最好选择。

7.7 状态机和间接跳转

汇编语言程序中常见的另一种控制结构是状态机。简单来说,状态机 是一段通过进入和退出某些状态来跟踪其执行历史的代码。状态机使用 状态变量 来控制程序流程。FORTRAN 编程语言通过分配的 goto 语句提供了这种能力。某些 C 的变种,如自由软件基金会的 GNU GCC,也提供了类似的功能。在汇编语言中,间接跳转可以实现状态机。

从某种意义上讲,所有程序都是状态机。CPU 寄存器和内存中的值构成了该机器的状态。然而,本章使用了一个更为有限的定义。在大多数情况下,只有一个单一的变量(或 PC 寄存器中的值)表示当前状态。

以状态机为具体例子,假设你有一个过程,并且希望在第一次调用时执行一个操作,第二次调用时执行另一个操作,第三次调用时执行第三个操作,然后在第四次调用时执行一个新的操作。第四次调用后,代码将按顺序重复这四个操作。例如,假设你希望该过程第一次调用时将 W0 和 W1 相加,第二次调用时将它们相减,第三次调用时将它们相乘,第四次调用时将它们相除。你可以按以下列表 7-4 实现此过程。

// Listing7-4.S
//
// A simple state machine example

#include    "aoaa.inc"

❶ #define     state   x19

            .code
            .extern printf

ttlStr:     wastr   "Listing 7-4"
fmtStr0:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 5, W21 = 6\n"

fmtStr0b:   .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 1, W21 = 2\n"

fmtStrx:    .ascii  "Back from StateMachine, "
            wastr   "state=%d, W20=%d\n"

fmtStr1:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 50, W21 = 60\n"

fmtStr2:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 10, W21 = 20\n"

fmtStr3:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 50, W21 = 5\n"

// getTitle
//
// Return pointer to program title
// to the C++ code.

            proc    getTitle, public
            adr     x0, ttlStr
            ret
            endp    getTitle

// State machine is a leaf procedure. Don't bother
// to save LR on stack.
//
// Although "state" is technically a nonvolatile
// register, the whole point of this procedure
// is to modify it, so we don't preserve it.
// Likewise, X20 gets modified by this code,
// so it doesn't preserve its value either.

            proc   StateMachine
            cmp    state, #0
            bne    TryState1

// State 0: Add W21 to W20 and switch to state 1:

            add    w20, w20, w21
            add    state, state, #1  // State 0 becomes state 1.
            b.al   exit

TryState1:
            cmp    state, #1
            bne    TryState2

// State 1: Subtract W21 from W20 and switch to state 2:

            sub    w20, w20, w21
            add    state, state, 1   // State 1 becomes state 2.
            b.al   exit

TryState2:  cmp    state, #2
            bne    MustBeState3

// If this is state 2, multiply W21 by W20 and switch to state 3:

            mul    w20, w20, w21
            add    state, state, #1  // State 2 becomes state 3.
            b.al   exit

// If it isn't one of the preceding states, we must be in
// state 3, so divide W20 by W21 and switch back to state 0.

MustBeState3:
            sdiv    w20, w20, w21
            mov     state, #0        // Reset the state back to 0.

exit:       ret
            endp    StateMachine

/////////////////////////////////////////////////////////
//
// Here's the asmMain procedure:

            proc    asmMain, public

            locals  am
            dword   saveX19
            dword   saveX2021
 byte    stackSpace, 64
            endl    am

            enter   am.size

// Save nonvolatile registers and initialize
// them to point at xArray, yArray, zArray,
// tArray, aArray, and bArray:

          ❷ str     state, [fp, #saveX19]
            stp     x20, x21, [fp, #saveX2021]
          ❸ mov     state, #0

// Demonstrate state 0:

            lea     x0, fmtStr0
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

            mov     x20, #5
            mov     x21, #6
            bl      StateMachine

          ❹ lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 1:

            lea     x0, fmtStr1
            mov     x1, state
            bl      printf

            mov     x20, #50
            mov     x21, #60
            bl      StateMachine

          ❺ lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 2:

            lea     x0, fmtStr2
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

 mov     x20, #10
            mov     x21, #20
            bl      StateMachine

          ❻ lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 3:

            lea     x0, fmtStr3
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

            mov     x20, #50
            mov     x21, #5
            bl      StateMachine

          ❼ lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate back in state 0:

            lea     x0, fmtStr0b
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

            mov     x20, #1
            mov     x21, #2
            bl      StateMachine

          ❽ lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Restore nonvolatile register values
// and return.

            ldr     state, [fp, #saveX19]
            ldp     x20, x21, [fp, #saveX2021]
            leave   // Return to C/C++ code.
            endp    asmMain

这段代码使用 X19 来维护状态变量 ❶。主程序保存 X19(和 X20)❷,然后将状态机初始化为状态 0 ❸。接着,代码依次调用状态机函数,并打印从状态 0 ❹、1 ❺、2 ❻ 和 3 ❼ 的结果。执行完状态 3 后,代码返回到状态 0 并打印结果 ❽。

这是构建命令和程序输出:

$ ./build Listing7-4
$ ./Listing7-4
Calling Listing7-4:
Calling StateMachine, state=0, W20 = 5, W21 = 6
Back from StateMachine, state=1, W20 = 11
Calling StateMachine, state=1, W20 = 50, W21 = 60
Back from StateMachine, state=2, W20=-10
Calling StateMachine, state=2, W20 = 10, W21 = 20
Back from StateMachine, state=3, W20 = 200
Calling StateMachine, state=3, W20 = 50, W21 = 5
Back from StateMachine, state=0, W20 = 10
Calling StateMachine, state=0, W20 = 1, W21 = 2
Back from StateMachine, state=1, W20 = 3
Listing7-4 terminated

从技术上讲,StateMachine 过程本身并不是状态机。实际上,状态变量和 cmp/bne 指令构成了状态机。这个过程不过是通过 if...then...elseif 构造实现的一个 switch 语句。唯一独特的是,它记住了它被调用的次数(或者更准确地说,是按 4 的模数计算的调用次数),并根据调用次数的不同而表现得不同。

虽然这是一个正确的状态机实现,但它并不特别高效。机智的读者可能会意识到,通过使用实际的 switch 语句而不是 if...then...elseif...endif 实现,可以让这段代码运行得更快。然而,还有一个更好的解决方案。

在汇编语言中,使用间接跳转来实现状态机是很常见的。我们可以不使用包含 0、1、2 或 3 之类值的状态变量,而是将状态变量加载为进入过程时要执行的代码的地址。通过简单地跳转到该地址,状态机可以省去选择合适代码片段所需的测试。考虑一下列表 7-5 中使用间接跳转的实现。

// Listing7-5.S
//
// An indirect jump state machine example

#include    "aoaa.inc"

#define     state   x19

 .code
            .extern printf

ttlStr:     wastr   "Listing 7-5"
fmtStr0:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 5, W21 = 6\n"

fmtStr0b:   .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 1, W21 = 2\n"

fmtStrx:    .ascii  "Back from StateMachine, "
            wastr   "state=%d, W20=%d\n"

fmtStr1:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 50, W21 = 60\n"

fmtStr2:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 10, W21 = 20\n"

fmtStr3:    .ascii  "Calling StateMachine, "
            wastr   "state=%d, W20 = 50, W21 = 5\n"

// getTitle
//
// Return pointer to program title
// to the C++ code.

            proc    getTitle, public
            adr     x0, ttlStr
            ret
            endp    getTitle

// State machine is a leaf procedure. Don't bother
// to save LR on stack.
//
// Although "state" is technically a nonvolatile
// register, the whole point of this procedure
// is to modify it, so we don't preserve it.
// Likewise, x20 gets modified by this code,
// so it doesn't preserve its value either.

            proc    StateMachine
          ❶ br      state   // Transfer control to current state.

// State 0: Add W21 to W20 and switch to state 1:

state0:
            add   w20, w20, w21
          ❷ adr   state, state1   // Set next state.
            ret

// State 1: Subtract W21 from W20 and switch to state 2:

state1:
            sub   w20, w20, w21
 adr   state, state2   // Switch to state 2.
            ret

// If this is state 2, multiply W21 by W20 and switch to state 3:

state2:
            mul   w20, w20, w21
            adr   state, state3   // Switch to state 3.
            ret

// If it isn't one of the preceding states, we must be in
// state 3, so divide W20 by W21 and switch back to state 0.

state3:
            sdiv    w20, w20, w21
            adr     state, state0
            ret
            endp    StateMachine

/////////////////////////////////////////////////////////
//
// Here's the asmMain procedure:

            proc    asmMain, public

            locals  am
            dword   saveX19
            dword   saveX2021
            byte    stackSpace, 64
            endl    am

            enter   am.size

// Save nonvolatile registers and initialize
// them to point at xArray, yArray, zArray,
// tArray, aArray, and bArray:

            str     state, [fp, #saveX19]
            stp     x20, x21, [fp, #saveX2021]

// Initialize state machine:

          ❸ adr     state, state0

// Demonstrate state 0:

            lea     x0, fmtStr0
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

 mov     x20, #5
            mov     x21, #6
            bl      StateMachine

            lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 1:

            lea     x0, fmtStr1
            mov     x1, state
            bl      printf

            mov     x20, #50
            mov     x21, #60
            bl      StateMachine

            lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 2:

            lea     x0, fmtStr2
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

            mov     x20, #10
            mov     x21, #20
            bl      StateMachine

            lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate state 3:

            lea     x0, fmtStr3
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

 mov     x20, #50
            mov     x21, #5
            bl      StateMachine

            lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Demonstrate back in state 0:

            lea     x0, fmtStr0b
            mov     x1, state
            mstr    x1, [sp]
            bl      printf

            mov     x20, #1
            mov     x21, #2
            bl      StateMachine

            lea     x0, fmtStrx
            mov     x1, state
            mov     x2, x20
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

// Restore nonvolatile register values
// and return:

            ldr     state, [fp, #saveX19]
            ldp     x20, x21, [fp, #saveX2021]
            leave   // Return to C/C++ code.
            endp    asmMain

这段代码与列表 7-4 的结构相同。主要的不同之处在于,这段代码假设状态机的目标地址在 X19 中,而不是一个状态编号。

在 StateMachine 过程的开始部分,br 指令❶将控制权转移到由状态变量(X19)指向的位置。第一次调用 StateMachine 时,它指向 state0 标签。之后,每个代码子段都会将状态变量设置为指向相应的后续代码。在每个状态机状态内❷,代码将 X19 设置为状态机下一个入口点的地址(而不是设置状态编号)。主程序使用 State0 标签的地址❸来初始化状态机,而不是使用值 0。除此之外,这个主程序与列表 7-4 中的基本相同。

这是构建命令和程序输出:

$ ./build Listing7-5
$ ./Listing7-5
Calling Listing7-5:
Calling StateMachine, state=4196420, W20 = 5, W21 = 6
Back from StateMachine, state=4196432, W20 = 11
Calling StateMachine, state=4196432, W20 = 50, W21 = 60
Back from StateMachine, state=4196444, W20=-10
Calling StateMachine, state=4196444, W20 = 10, W21 = 20
Back from StateMachine, state=4196456, W20 = 200
Calling StateMachine, state=4196456, W20 = 50, W21 = 5
Back from StateMachine, state=4196420, W20 = 10
Calling StateMachine, state=4196420, W20 = 1, W21 = 2
Back from StateMachine, state=4196432, W20 = 3
Listing7-5 terminated

这个输出展示了列表 7-5 的行为与列表 7-4 类似。

7.8 循环

循环是构成典型程序的最后一种基本控制结构(顺序、选择和循环)。大多数高级语言(HLL)都将隐式的循环结构隐藏了起来。例如,考虑 BASIC 语句 if A$ = B$ then 100。这个 if 语句比较两个字符串,如果它们相等,就跳转到语句 100。在汇编语言中,你需要编写一个循环,逐个比较 A$ 中的每个字符与 B$ 中对应位置的字符,然后仅当所有字符匹配时才跳转到语句 100。(C 标准库提供了 strcmp 例程来为你比较字符串,实际上隐藏了这个循环。然而,如果你自己编写这个函数,操作的循环性质将变得显而易见。)

程序循环由三个部分组成:一个可选的初始化部分,一个可选的循环终止测试,和循环体。你将这些部分组装的顺序可以显著影响循环的执行。程序中经常出现三种排列组合:while 循环、repeat...until 循环(C/C++ 中的 do...while)和无限循环(例如 C/C++ 中的 for(;;))。本节将涵盖这三种循环类型,以及 C 风格的 for 循环(确定性循环)、循环中的寄存器使用和如何跳出循环。

7.8.1 while

最通用的循环是 while 循环。在 C/C++ 中,它采用以下形式:

while(`expression`) `statement(s)`; 

while 循环中,终止测试出现在循环开始时。由于终止测试的位置,循环体可能永远不会执行,如果布尔表达式始终为假。

考虑以下 C/C++ while 循环:

i = 0; 
while(i < 100) 
{
    ++i; 
}

i = 0; 语句是此循环的初始化代码。i 是一个循环控制变量,因为它控制着循环体的执行。i < 100 是循环终止条件:只要 i 小于 100,循环就不会终止。单个语句 ++i;(递增 i)是循环体,在每次循环迭代时执行。

C/C++ while 循环可以通过 ifgoto 语句轻松合成。例如,你可以用以下 C 代码替代前面的 C while 循环:

i = 0; 
WhileLp: 
if(i < 100) 
{

    ++i; 
      goto WhileLp; 

} 

更一般地,你可以按如下方式构造任何while循环:

`Optional initialization code` 

UniqueLabel: 
if(`not_termination_condition`) 
{
    `Loop body` 
    goto UniqueLabel; 

} 

因此,你可以使用本章前面提到的技术,将 if 语句转换为汇编语言,并添加一个 b.al 指令来生成 while 循环。本节中的示例将转换为以下纯 ARM 汇编代码:

 mov  w0, #0 
WhileLp: 
          cmp  w0, #100 
          bnlt WhileDone 

 `Loop body` 

          add  w0, w0, #1   // ++i 
          b.al WhileLp 

WhileDone: 

GCC 实际上会将大多数 while 语句转换为与本节展示的不同的 ARM 代码。差异的原因出现在第 7.9.1 节,“将终止条件移动到循环末尾”,在第 428 页中,探讨了如何编写更高效的循环代码。

7.8.2 repeat...until

repeat...until 循环,也称为 C 中的 do...while 循环,它在循环的末尾测试终止条件,而不是在循环开始时。在 Pascal 中,repeat...until 循环采用以下形式:

`Optional initialization code` 
repeat 

    `Loop body` 

until(`termination_condition`); 

这与以下的 C/C++ do...while 循环相当:

`Optional initialization code` 
do 
{
    `Loop body` 

}while(`not_termination_condition`); 

这个序列执行初始化代码,然后执行循环体,最后测试一个条件以判断循环是否应该重复。如果布尔表达式的结果为假,循环会重复;否则,循环终止。在 repeat...until 循环中,终止测试出现在循环末尾,因此,循环体总是至少执行一次。

while 循环类似,repeat...until 循环可以通过 if 语句和 b.al(分支)语句合成。以下是这样一种实现的示例:

`Initialization code` 
SomeUniqueLabel: 

    `Loop body` 

if(`not_the_termination_condition`) goto SomeUniqueLabel; 

基于前面章节中提供的内容,你可以轻松地在汇编语言中合成repeat...until循环,如以下简单示例所示:

 repeat (* Pascal code *)

          write('Enter a number greater than 100:'); 
          readln(i); 

     until(i > 100); 

// This translates to the following if/jmp code: 

     RepeatLabel: 

          write('Enter a number greater than 100:'); 
          readln(`i`); 

     if(`i` <= 100) then goto RepeatLabel; 

// It also translates into the following assembly code: 

RepeatLabel: 

          bl    print 
          wastr "Enter a number greater than 100: "
          bl    readInt // Function to read integer from user 

          cmp   w0, #100 // Assume readInt returns integer in W0\. 
          bngt  RepeatLabel 

repeat...until循环有一个稍微高效的实现,因为它结合了循环终止测试和跳转回循环开始的操作。

7.8.3 forever/endfor

如果while循环在循环开始时测试终止条件,而repeat...untildo...while循环在循环结束时测试终止条件,那么唯一剩下的测试终止条件的位置就是循环中间。C/C++的高级for(;;)循环,结合break语句,提供了这个功能。C/C++的无限循环采用以下形式:

for(;;)
{
    `Loop body` 

} 

没有显式的终止条件。for(;;)结构形成了一个无限循环。break语句通常用于处理循环的终止。考虑以下使用for(;;)结构的 C++代码:

for(;;)
{
     cin >> `character`; 
     if(`character` == '.') break; 
     cout << `character`; 

} 

将一个for(;;)循环转换为纯汇编语言是很简单的:你只需要一个标签和一个b.al指令。此示例中的break语句也仅仅是一个b.al指令(或条件跳转)。上述代码的纯汇编语言版本大致如下:

foreverLabel: 

          bl   getchar    // Assume it returns char in W0\. 
          cmp  w0, #'.' 
          beq  ForIsDone 

          bl   putcchar   // Assume this prints the char in W0\. 
          b.al foreverLabel 

ForIsDone: 

如你所见,forever循环有一个非常简单的实现。

7.8.4 for

标准的for循环是while循环的一种特殊形式,它使得循环体重复特定次数,这被称为确定性循环。在 C/C++中,for循环的形式如下:

for(`Initialization_Stmt`; `Termination_Expression`; `inc_Stmt`) 
{
    `statements` 

} 

这等价于以下内容:

`Initialization_Stmt`; 
while(`Termination_Expression`) 
{
    `statements` 

    `inc_Stmt`; 

} 

传统上,程序使用for循环来处理数组和其他按顺序访问的对象。你通常用初始化语句初始化一个循环控制变量,然后将该变量作为数组(或其他数据类型)的索引,如以下示例所示:

for(i = 0; i < 7; ++i) 
{
    printf("Array Element = %d /n", SomeArray[i]); 

} 

要将其转换为纯汇编语言,首先将for循环转换为等效的while循环:

i = 0; 
while(i < 7) 
{
    printf("Array Element = %d \n", SomeArray[i]); 
    ++i; 
} 

现在,使用第 7.8.1 节“while”中的技术(见第 415 页),将代码翻译成纯汇编语言:

 mov  x19, #0               // Use X19 to hold loop index. 
WhileLp:  cmp  x19, #7 
          bnlt EndWhileLp 

          lea  x0, fmtStr            // fmtStr = "Array Element = %d\n"
          lea  x1, SomeArray 
          ldr  w1, [x1, x19, lsl #2] // SomeArray is word array. 
          mstr x1, [sp] 
          bl   printf 

          add  x19, x19, #1   // ++i 
          b.al WhileLp; 

EndWhileLp: 

这是在汇编语言中实现while循环的一个相当高效的实现,尽管对于执行固定次数的for循环,你可能会考虑使用cbnz指令(见第 7.8.6 节,“ARM 循环指令”,第 425 页)。

7.8.5 break 和 continue

C/C++的breakcontinue语句都转换为单一的b.al指令。break语句退出直接包含break语句的循环;continue语句重新启动包含continue语句的循环。

要将break语句转换为纯汇编语言,只需发出一个goto/b.al指令,将控制转移到循环结束后紧接着的第一条语句以退出循环。你可以通过在循环体后放置一个标签并跳转到该标签来实现这一点。以下代码片段演示了这种技术在不同循环中的应用:

// Breaking out of a FOR(;;) loop: 

for(;;)
{
 `stmts` 
      // break; 
      goto BreakFromForever; 
    `  stmts` 
}
BreakFromForever: 

// Breaking out of a FOR loop: 

for(`initStmt`; `expr; incStmt`) 
{
      `stmts` 

      // break; 
      goto BrkFromFor; 

      `stmts` 
}
BrkFromFor: 

// Breaking out of a WHILE loop: 

while(`expr`) 
{
      `stmts` 

      // break; 
      goto BrkFromWhile; 

      `stmts` 
}
BrkFromWhile: 

// Breaking out of a REPEAT...UNTIL loop (do...while is similar): 

repeat 
      `stmts` 

      // break; 
      goto BrkFromRpt; 

      `stmts` 
until(`expr`); 
BrkFromRpt: 

在纯汇编语言中,将适当的控制结构转换为汇编,并将 goto 替换为 b.al 指令。

continue 语句比 break 语句稍微复杂一些。实现仍然是一个 b.al 指令;然而,目标标签在每个循环中不会出现在相同的位置。图 7-2 至 图 7-5 显示了每个循环中 continue 语句的控制转移位置。

图 7-2 显示了带有 continue 语句的 for(;😉 循环。

图 7-2:continue 目标和 for(;😉 循环

图 7-3 显示了带有 continue 语句的 while 循环。

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

图 7-4 显示了带有 continue 语句的 C/C++ for 循环。

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

请注意,在 图 7-4 中,continue 语句强制执行 incStmt,然后将控制权转移到测试循环终止条件的位置。

图 7-5 显示了一个带有 continue 语句的 repeat...until 循环。

图 7-5:continue 目标和 repeat...until 循环

以下代码片段演示了如何将 continue 语句转换为每种循环类型的适当 b.al 指令:

// for(;;)/continue/endfor 
// Conversion of forever loop with continue 
// to pure assembly: 
// for(;;)
// {
//      `stmts` 
//      continue; 
//      `stmts` 
//}
//
// Converted code: 

foreverLbl: 

      `stmts` 

      // continue; 
      b.al foreverLbl 

      `stmts` 

      b.al foreverLbl 

// while/continue/endwhile 
// Conversion of while loop with continue 
// into pure assembly: 
//
// while(expr) 
// {
//      `stmts` 
//      continue; 
//      `stmts` 
//}
//
// Converted code: 

whlLabel: 

 `Code to evaluate expr` 

 b`cc` EndOfWhile      // Skip loop on expr failure. 

      `stmts` 

      // continue; 
      b.al whlLabel      // Jump to start of loop on continue. 

      `stmts` 

      b.al whlLabel      // Repeat the code. 
EndOfWhile: 

// for/continue/endfor 
// Conversion for a for loop with continue 
// into pure assembly: 
//
// for(`initStmt`; `expr`; `incStmt`) 
// {
//      `stmts` 
//      continue; 
//      `stmts` 
//}
//
// Converted code: 

        `initStmt` 
ForLpLbl: 

 `Code to evaluate expr` 

        b`cc` EndOfFor     // Branch if expression fails. 

        `stmts` 

        // continue; 
        b.al ContFor     // Branch to incStmt on continue. 

        `stmts` 

ContFor: 

        `incStmt` 

        b.al ForLpLbl 

EndOfFor: 

// repeat...continue...until 
// repeat 
//      `stmts` 
//      continue; 
//      `stmts` 
// until(`expr`); 
//
// do 
// {
//      `stmts` 
//      continue; 
//      `stmts` 
//
//}while(!`expr`); 
//
// Converted code: 

RptLpLbl: 

      `stmts` 

      // continue; 
      b.al ContRpt     // Continue branches to termination test. 

      `stmts` 

ContRpt: 

  `Code to test expr` 

      b`cc` RptLpLbl     // Jumps if expression evaluates false. 

在每种情况下,b.al 指令将控制权转移到循环中测试循环条件并增加循环控制变量(对于 for 循环),或者转移到循环体的开头。

7.8.6 ARM 循环指令

ARM CPU 提供了四条机器指令,适用于创建循环。这些指令违反了 RISC 原则“每条指令只做一件事”,但它们还是非常实用,即使它们有些“CISC”风格。

前两条指令测试寄存器的值,并在该寄存器等于或不等于 0 时进行分支。这两条指令是 cbz(比较并在零时分支)和 cbnz(比较并在非零时分支)。它们的语法是:

cbz  w`n`, `label` 
cbz  x`n`, `label` 
cbnz w`n`, `label` 
cbnz x`n`, `label` 

其中,Xn 和 Wn 是要与 0 比较的寄存器,label 是当前指令 ±1MB 范围内的语句标签。

这些指令等价于以下内容:

cmp w`n`, wzr   // cbz w`n`, `label` 
beq `label` 

cmp x`n`, xzr   // cbz x`n`, `label` 
beq `label` 

cmp w`n`, wzr   // cbnz w`n`, `label` 
bne `label` 

cmp x`n`, xzr   // cbnz x`n`, `label` 
bne `label` 

另一对有用的指令是 tbz(测试位为 0 并分支)和 tbnz(测试位非 0 并分支)。这些指令会测试寄存器中的某个位,并根据该位的值(0 或非零)进行分支。这些指令的语法是:

tbz  w`n`, #`imm`6, `label` 
tbz  x`n`, #`imm`6, `label` 
tbnz w`n`, #`imm`6, `label` 
tbnz x`n`, #`imm`6, `label` 

其中,Xn 和 Wn 是要测试的寄存器,imm**[6] 是 64 位寄存器范围内 0–63 位的位数,或 32 位寄存器范围内 0–31 位的位数,label 是当前指令 ±32KB 范围内的语句标签。tbz 指令会在寄存器中的指定位为 0 时跳转到该标签,而 tbnz 指令则在该位不为 0 时跳转。

7.8.7 寄存器使用与循环

鉴于 ARM 对寄存器的访问效率比内存位置更高,寄存器是放置循环控制变量的理想位置(特别是对于小循环)。然而,寄存器是有限资源,尽管 ARM 提供了许多寄存器。与内存不同,你不能在寄存器中放入大量数据。

循环对寄存器提出了特殊挑战。寄存器非常适合用作循环控制变量,因为它们操作高效,并且可以作为数组和其他数据结构的索引(这是循环控制变量的常见用途)。然而,由于寄存器的数量有限,当以这种方式使用寄存器时,往往会出现问题。如果在循环内调用其他函数/过程,这会使你只能使用非易失性寄存器来作为循环控制变量。这在以下代码中表现得尤为明显,嵌套循环无法正常工作,因为它尝试重用已经在使用的寄存器(X19),导致外层循环的控制变量被损坏:

 mov  w19, #8 
loop1: 
          mov  w19, #4 
loop2: 
          `stmts` 

 subs w19, w19, #1 
          bne  loop2 

          subs w19, w19, #1 
          bne  loop1 

这里的目的是创建一组嵌套循环,一个循环嵌套在另一个循环内。内层循环(loop2)应该在外层循环(loop1)执行八次时重复执行四次。不幸的是,这两个循环使用相同的寄存器作为循环控制变量。因此,这会形成一个无限循环。因为 W19 在遇到第二个 subs 指令时总是为 0,所以控制总是会转移到 loop1 标签(因为将 0 减去会产生非零结果)。

解决方案是保存并恢复 W19 寄存器,或者使用不同的寄存器代替 W19 作为外层循环的控制变量;以下代码展示了如何在循环执行过程中保存 W19:

 mov w19, #8 
loop1: 
          str  w19, [sp, #-16]!   // Push onto stack. 
          mov  w19, #4 
loop2: 
        `  stmts` 

          subs w19, w19, #1 
          bne  loop2 

          ldr  w19, [sp], #16     // Pop off the stack. 
          subs w19, w19, #1       // Decrement W19\. 
          bne  loop1 

`or` 

          mov  w19,#8 
loop1: 
          mov  w20, #4 
loop2: 
          `stmts` 

          subs w20, w20, #1 
          bne  loop2 

          subs w19, w19, #1 
          bne  loop1 

寄存器损坏是汇编语言程序中循环错误的主要来源之一,因此要时刻留意这个问题。

到目前为止,本章主要集中在汇编语言中各种类型循环的正确实现。下一部分将开始讨论如何在汇编语言中高效编写循环。

7.9 循环性能改进

由于循环是程序中性能问题的主要来源,因此在尝试加速软件时,循环是最需要关注的地方。有关如何编写高效程序的详细讨论超出了本章的范围,但在设计程序中的循环时,你应该了解以下概念。这些概念的目的都是通过去除循环中的不必要指令,减少每次循环迭代的执行时间。

7.9.1 将终止条件移至循环末尾

正如你可能已经注意到的,repeat...until 循环比 while 循环稍微高效。这是因为 repeat...until 成功地将循环的布尔测试与返回到循环起始点的分支结合在了一起。你可以改进其他循环,使其稍微高效一些。考虑以下三种类型的循环的流程图:

`repeat...until loop:` 
 `Initialization code` 
 `Loop body` 
 `Test for termination and branch back if necessary.` 
 `Code following the loop` 

`while loop:` 
 `Initialization code` 
 `Loop-termination test` 
 `Loop body` 
 `Jump back to test.` 
 `Code following the loop` 

`forever/endfor loop:` 
 `Initialization code` 
 `Loop body part 1` 
 `Loop-termination test` 
 `Loop body part 2` 
 `Jump back to loop body part 1` 
     `Code following the loop` 

repeat...until 循环是这些循环中最简单的。这一点在这些循环的汇编语言实现中得到了体现。考虑以下语义完全相同的 repeat...untilwhile 循环:

// Example involving a while loop: 

         mov  w0, w1 
         sub  w0, w0, #20 

// while(W0 <= W1) 

whileLp: cmp  w0, w1 
         bnle endwhile 

         `stmts` 

         add  w0, w0, #1 
         b.al whileLp 
endwhile: 

// Example involving a repeat...until loop: 

         mov  w0, w1 
         sub  w0, w0, #20 
repeatLp: 

        ` stmts` 

         add  w0, w0, #1 
         cmp  w0, w1 
         bngt repeatLp 

在循环末尾测试终止条件允许你从循环中移除一个 b.al 指令,如果循环嵌套在其他循环中,这可能会非常重要。根据循环的定义,你可以轻松看出该循环将精确执行 20 次,这表明将其转换为 repeat...until 循环是微不足道且始终可行的。不幸的是,这并不总是这么简单。

考虑以下 C 代码:

while(w0 <= w1) 
{
    `stmts` 

    ++w0; 
}

在这个例子中,你不知道进入循环时 W0 中包含什么。因此,你不能假设循环体至少会执行一次。这意味着你必须在执行循环体之前测试循环是否终止。这个测试可以放在循环的末尾,并加上一条 b.al 指令:

 b.al WhlTest 
TopOfLoop: 

        `  stmts` 

          add  w0, w0, #1 
WhlTest:  cmp  w0, w1 
          ble TopOfLoop 

尽管代码与原始 while 循环一样长,但 b.al 指令仅执行一次,而不是在每次循环时都执行。然而,效率的轻微提升是通过略微降低可读性来实现的,因此一定要加上注释。第二段代码也比原始实现更接近“意大利面条代码”(spaghetti code)。这种微小的性能提升常常需要付出可读性的代价。仔细分析你的代码,确保这种性能提升值得牺牲清晰度。

7.9.2 反向执行循环

由于 ARM 上标志的特性,从某个数递减到(或递增到)0 的循环比从 0 执行到另一个值的循环更高效。比较以下的 C/C++ for 循环和相应的汇编语言代码:

for(j = 1; j <= 8; ++j) 
{
    `  stmts` 
}

// Conversion to pure assembly (as well as using a 
// REPEAT...UNTIL form): 

mov w0, #1     // Assume j = W0\. 
ForLp: 

      `stmts` 

      add w0, w0, #1 
      cmp w0, #8 
      ble ForLp 

现在考虑另一个循环,它也有八次迭代,但它的循环控制变量从 8 递减到 1,而不是从 1 递增到 8,从而在每次循环时节省一次比较操作:

 mov  w0, #8  // Assume j = W0\. 
LoopLbl: 

    ` stmts` 

     subs w0, w0, #1 
     bne  LoopLbl 

节省每次迭代中 cmp 指令的执行时间可能会导致更快的代码。不幸的是,你无法强制所有循环都反向执行。然而,通过一点努力和一些强制,你应该能够编写许多 for 循环,使其反向运行。

之前的例子效果很好,因为循环从 8 递减到 1。循环在循环控制变量变为 0 时终止。如果你需要在循环控制变量变为 0 时执行循环,会发生什么情况?例如,假设前面的循环需要从 7 递减到 0。如果下界是非负数,你可以用 bpl 指令代替早期代码中的 bne 指令:

 mov  w0, #7   // Assume j = W0\. 
LoopLbl: 

    ` stmts` 

     subs w0, w0, #1 
     bpl  LoopLbl 

这个循环将重复八次,W0(j)将取值从 7 到 0。当 W0 从 0 递减到 -1 时,它设置符号标志,循环终止。

请记住,有些值看起来是正数,但实际上是负数。如果循环控制变量是一个字(word),则在二进制补码系统中,范围从 2,147,483,648 到 4,294,967,295 的值是负数。因此,用此范围内的任何 32 位值(或者当然是 0)初始化循环控制变量,将在执行一次后终止循环。如果不小心,这可能会给你带来麻烦。

7.9.3 消除循环不变计算

A 循环不变计算 是一种出现在循环中的计算,其结果始终相同。你无需在循环内部进行这样的计算,而可以将它们计算到循环外部,并在循环内部引用计算的值。以下 C 代码演示了一个不变计算:

for(i = 0; i < n; ++i) 
{
    k = (j - 2) + i 
    `Other code` 
}

因为 j 在整个循环执行过程中始终不变,子表达式 j - 2 可以在循环外部计算:

jm2 = j - 2; 
for(i = 0; i < n; ++i) 
{

    k = jm2 + i; 
    `Other code` 
}

这转换为以下汇编代码,将不变计算移到循环外部:

 ldr  w19, [fp, #j] 
      sub  w19, w19, #2 
 mov  w20, #0         // Assume W20 = i. 
lp:   cmp  w20, #n 
      bnlt loopDone 
      add  w21, w19, w20   // k = jm2 + i 
      add  w20, w20, #1 

      `Other code` 

      b.al lp 
loopDone: 

7.9.4 解开循环

对于小循环——那些循环体仅由少数语句组成的循环——处理循环所需的开销可能占总处理时间的一个显著比例。例如,考虑以下 Pascal 代码及其对应的 ARM 汇编语言代码:

 for i := 3 downto 0 do A[i] := 0; 

          mov  w19, #3       // Assume i = W19\. 
          add  x20, fp, #A   // LEA X20,A, assuming A is local. 
LoopLbl: 
          str  wzr, [x20, x19, lsl #2] 
          subs w19, w19, #1 
          bpl  LoopLbl 

每次循环执行三条指令。只有一条指令在执行所需的操作(将 0 移动到 A 的一个元素中)。其余两条指令控制循环。因此,执行逻辑上由 4 所需的操作需要 12 条指令。

虽然基于迄今为止提供的信息我们可以对这个循环做出许多改进,但请仔细考虑这个循环到底在做什么:它将四个 0 存储到 A[0] 到 A[3] 中。一种更高效的方法是使用四条 str 指令来完成相同的任务。例如,如果 A 是一个字数组,以下代码比前面的代码更快地初始化 A:

str  wzr, [fp, #A + 0] 
str  wzr, [fp, #A + 4] 
str  wzr, [fp, #A + 8] 
str  wzr, [fp, #A + 12] 

虽然这是一个简单的例子,但它展示了循环解开(也称为循环展开)的好处,该方法通过在源代码中重复循环体来实现每次迭代。如果这个简单的循环出现在一组嵌套循环中,3:1 的指令减少可能会将该部分程序的性能提高一倍。(此时不提到你可以通过将双字存储到 A + 0 和 A + 8 来将其减少为两条指令,尽管那是另一种优化,实在是太不合适了。)

当然,你无法解开所有的循环。执行可变次数的循环很难解开,因为通常没有办法在汇编时确定循环的迭代次数。因此,解开循环是一种最适合应用于已知循环次数的循环的过程,且循环的执行次数在汇编时是已知的。

即使你重复执行一个固定次数的循环,它也可能不是循环解开的好候选。循环解开能带来显著的性能提升,特别是当控制循环的指令数量(以及处理其他开销操作的指令)占循环总指令数的一个重要比例时。如果前面的循环体包含了 36 条指令(不包括 3 条开销指令),性能提升最多也只能达到 10%,而现在的提升幅度为 300%到 400%。

因此,解开循环的成本——所有必须插入到程序中的额外代码——会随着循环体的增大或迭代次数的增加而迅速达到收益递减的临界点。此外,将这些代码输入到程序中也可能变得非常繁琐。因此,循环解开技术最好应用于小型循环。

7.9.5 使用归纳变量

本节介绍了基于归纳变量的优化。归纳变量是指其值完全依赖于另一个变量的值。考虑以下 Pascal 循环:

for i := 0 to 255 do csetVar[i] := [];

这里程序正在初始化字符集数组的每个元素为一个空集合。实现这一功能的直接代码如下:

 str  wzr, [fp, #i] 
     lea  x20, csetVar 
FLp: 

     // Assume that each element of a csetVar 
     // array contains 16 bytes (256 bits). 

     ldr  w19, [fp, #i] 
     lsl  w19, w19, #4     // i * 16 (element size) 

     // Set this element to the empty set (all 0 bits). 

     str  xzr, [x20, x19]  // Fill in first 8 bytes. 
     add  x20, x20, #8 
     str  xzr, [x20, x19]  // Initialize second 8 bytes. 
     sub  x20, x20, #8 

 ldr  w19, [fp, #i] 
     add  w19, w19, #1 
     str  w19, [fp, #i] 
     cmp  w19, #256     // Quit if at end of array. 
     blo  FLp 

尽管解开这段代码仍然会提高性能,但实现这一任务将需要 2,304 条指令——对于大多数应用程序而言,这个数量太多,只有在最为关键的时间敏感型应用中才可能考虑。然而,你可以通过使用归纳变量来减少循环体的执行时间。

在前面的示例中,数组 csetVar 的索引跟踪循环控制变量;它总是等于循环控制变量的值乘以 16。由于 i 在循环中没有出现在其他地方,因此没有必要对 i 进行计算。为什么不直接对数组索引值进行操作?此外,由于缩放索引寻址模式不支持整数偏移量成分,代码会不断地加 8 或减 8 来初始化每个字符集元素的后半部分。这一计算也可以被融入到循环控制变量的归纳中。以下代码演示了这一技巧:

 lea  x20, csetVar 
     add  x19, x20, #255 * 16  // Compute array ending address. 
FLp: 

     // Set current element to the empty set (all 0 bits). 

     str  xzr, [x20]       // Fill in first 8 bytes. 
     str  xzr, [x20, #8]   // Fill in second 8 bytes. 

     add  w20, x20, #16    // Move on to next element. 
     cmp  x20, x19 
     blo  FLp 

这个示例中的归纳发生在代码通过将数组的地址初始化到循环控制变量中(为了提高效率,将其移入 X20)并在每次循环迭代时将其增加 16,而不是增加 1。这使得代码可以使用间接加偏移寻址模式(而不是缩放索引寻址模式),因为不需要移位。一旦代码可以使用间接加偏移模式,它就可以省略对循环控制变量的加法和减法操作,以便访问每个字符集数组元素的后半部分。

7.10 继续前进

在掌握本章及之前各章的内容后,你应该能够将许多高级语言程序转换为汇编代码。

本章涵盖了有关汇编语言中实现循环的几个概念。讨论了语句标签,包括如何使用它们的地址,如何高效地在程序中表示指向标签的指针,使用无条件和间接分支,操作外壳,以及如何将控制转移到 ARM 分支范围之外的语句。接着讲解了决策:如何实现 if...then...else...elseif、switch 语句、汇编语言中的状态机、布尔表达式,以及完全/短路求值。还描述了如何利用 32 位 PC 相对地址来减小跳转表(和指针)的大小。最后,本章描述了各种类型的循环、提高循环性能以及支持循环构造的特殊 ARM 机器指令。

你现在已经准备好开始编写一些真正的汇编语言代码。从下一章开始,你将学习一些中级汇编语言编程,帮助你编写在高级语言中难以或不可能编写的代码。

7.11 更多信息

  • 我的书《编写优秀的代码》,第二卷,第二版(No Starch Press,2020)提供了关于在低级汇编语言中实现各种高级语言控制结构的良好讨论。它还讨论了诸如归纳、展开、强度减少等优化方法,这些方法适用于优化循环。

第三部分 高级汇编语言

第八章:8 高级算术

本章讲解了扩展精度算术和不同大小操作数的算术运算。到本章结束时,你应该能够了解如何对任意大小的整数操作数执行算术和逻辑运算,包括超过 64 位的操作数,以及如何将不同大小的操作数转换为兼容的格式。

8.1 扩展精度运算

汇编语言不限制整数运算的大小,这是与高级语言(HLLs)相比的一个主要优势,高级语言通常依赖于汇编语言编写的函数来处理扩展精度算术。例如,标准 C 语言定义了四种整数大小:short int、int、long int 和 long long int。在 PC 上,这些通常是 16 位、32 位和 64 位整数。

尽管 ARM 的机器指令限制你使用单一指令处理 32 位或 64 位整数,但你可以使用多个指令处理任意大小的整数。本节描述了如何将各种算术和逻辑操作从 32 位或 64 位扩展到任意位数。

8.1.1 加法

ARM 的add/adds指令可以加两个 32 位或 64 位数字。执行adds后,如果和的高位(HO 位)发生溢出,ARM 会设置进位标志。你可以利用这一信息进行扩展精度的加法运算。(本书将多位数多字节视为扩展精度的同义词。)

考虑你手动执行多位数加法运算的方式,如图 8-1 所示。

图 8-1:多位数加法

ARM 以相同的方式处理扩展精度算术,不同之处在于,它不是一次加一个数字,而是一次加一个字或双字,将较大的操作分解为一系列较小的操作。例如,考虑图 8-2 中展示的三双字(192 位)加法操作。

图 8-2:将两个 192 位对象相加

由于 ARM 处理器系列每次最多只能加 64 位(使用通用寄存器),因此运算必须按 64 位或更小的块进行,遵循以下步骤:

1.  就像在手动算法中将两个十进制数的低位数字相加一样,将两个低位双字相加,使用adds指令。如果低位相加时有进位,adds会将进位标志设置为 1;否则,进位标志会被清除。

2.  将两个 192 位值中的第二对双字以及前一次加法的进位(如果有)加在一起,使用adcs(带进位加法)指令。adcs指令的语法与adds相同,执行的几乎是相同的操作:

adcs dest, source1, source2 // dest := source1 + source2 + C

唯一的区别是 adcs 指令在加源操作数的同时加上了进位标志的值。它设置标志的方式与 adds 指令相同(包括在发生无符号溢出时设置进位标志)。这正是我们需要的,以便将 192 位和的中间两个双字加在一起。

  1. 使用 adcs 将 192 位值的高阶双字与中间两个四字的和的进位相加。(如果你不需要指令后的标志设置,也可以使用普通的 adc 指令。)

总结来说,adds 指令将低阶双字相加,而 adcs 指令将所有其他双字对相加。在扩展精度加法序列结束时,进位标志表示无符号溢出(如果设置),溢出标志表示符号溢出,而符号标志表示结果的符号。零标志在扩展精度加法结束时没有实际意义;它仅表示两个高阶双字的和为 0,并不表示整个结果为 0。

例如,假设你有两个 128 位的值需要相加,定义如下:

 .data
X:      .qword   0
Y:      .qword   0

假设你想将和存储在第三个变量 Z 中,Z 也是一个四字。以下 ARM 代码将完成这项任务:

lea   x0, X
ldr   x3, [x0]    // Add together the LO 64 bits
lea   x1, Y       // of the numbers and store the
ldr   x4, [x1]    // result into the LO dword of Z.
adds  x5, x3, x4
lea   x2, Z
str   x5, [x2]

ldr   x3, [x0, #8]    // Add together the HO 64 bits (with
ldr   x4, [x1, #8]    // carry) and store the result into
adcs  x5, x3, x4      // the HO dword of Z.
str   x5, [x2, #8]

前七条指令将 X 和 Y 的低阶双字相加,并将结果存储到 Z 的低阶双字中。最后四条指令将 X 和 Y 的高阶双字加在一起,并加上低阶字的进位,将结果存储到 Z 的高阶双字中。

你可以通过使用 adcs 指令将更高阶的值加在一起,来扩展此算法到任何位数。例如,要将两个声明为四个双字数组的 256 位值加在一起,你可以使用如下代码:

 .data
BigVal1: .space  4*8        // Array of four double words
BigVal2: .space  4*8
BigVal3: .space  4*8        // Holds the sum
     .
     .
     .
    lea  x0, BigVal1
    lea  x1, BigVal2
    lea  x2, BigVal3

 ldr  x4, [x0]           // BigVal1[0]
    ldr  x5, [x1]           // BigVal2[0]
    adds x6, x4, x5
    str  x6, [x2]           // BigVal3[0]

    ldr  x4, [x0, #8]       // BigVal1[1]
    ldr  x5, [x1, #8]       // BigVal2[1]
    adcs x6, x4, x5
    str  x6, [x2, #8]       // BigVal3[1]

    ldr  x4, [x0, #16]      // BigVal1[2]
    ldr  x5, [x1, #16]      // BigVal2[2]
    adcs x6, x4, x5
    str  x6, [x2, #16]      // BigVal3[2]

    ldr  x4, [x0, #24]      // BigVal1[3]
    ldr  x5, [x1, #24]      // BigVal2[3]
    adcs x6, x4, x5
    str  x6, [x2, #24]      // BigVal3[3]

这会产生一个 256 位的和,并将其存储在内存位置 BigVal3 中。

8.1.2 减法

ARM 也以手动方式执行多字节减法,只是它一次减去整字或双字,而不是十进制位。使用 subs 指令在低阶字或双字上进行操作,并在高阶值上使用 sbc/sbcs(带进位减法)指令。

以下示例演示了使用 ARM 的 64 位寄存器进行 128 位减法:

 .data
Left:   .qword   .-.
Right:  .qword   .-.
Diff:   .qword   .-.
     .
     .
     .
    lea  x0, Left
    ldr  x3, [x0]
    lea  x1, Right
    ldr  x4, [x1]
    subs x5, x3, x4
    lea  x2, Diff
    str  x5, [x2]

    ldr  x3, [x0, #8]
    ldr  x4, [x1, #8]
    sbcs x5, x3, x4
    str  x5, [x2, #8]

以下示例演示了 256 位减法:

 .data
BigVal1: .space  4*8      // Array of four dwords
BigVal2: .space  4*8
BigVal3: .space  4*8
     .
     .
     .

// Compute BigVal3 := BigVal1 - BigVal2.

    lea  x0, BigVal1
    lea  x1, BigVal2
    lea  x2, BigVal3

    ldr  x4, [x0]           // BigVal1[0]
    ldr  x5, [x1]           // BigVal2[0]
    subs x6, x4, x5
    str  x6, [x2]           // BigVal3[0]

    ldr  x4, [x0, #8]       // BigVal1[1]
    ldr  x5, [x1, #8]       // BigVal2[1]
    sbcs x6, x4, x5
    str  x6, [x2, #8]       // BigVal3[1]

    ldr  x4, [x0, #16]      // BigVal1[2]
    ldr  x5, [x1, #16]      // BigVal2[2]
    sbcs x6, x4, x5
    str  x6, [x2, #16]      // BigVal3[2]

    ldr  x4, [x0, #24]      // BigVal1[3]
    ldr  x5, [x1, #24]      // BigVal2[3]
    sbcs x6, x4, x5
    str  x6, [x2, #24]      // BigVal3[3]

这会产生一个 256 位的差值,并将其存储在内存位置 BigVal3 中。

8.1.3 比较

不幸的是,没有可以用来执行扩展精度比较的“带进位比较”指令。然而,你可以通过仅使用 cmp 指令来比较扩展精度值。

假设你想比较两个无符号值 0x2157 和 0x1293。这两个值的低字节不会影响比较的结果。仅比较高字节,0x21 与 0x12,就能得出第一个值大于第二个值的结论。

如果高位字节相等,则必须查看一对值的两个字节。在其他情况下,比较高位字节即可得知有关值的所有信息。这适用于任何字节数,而不仅仅是两个字节。以下代码首先比较两个有符号 128 位整数的高位双字,如果高位四字相等,则再比较它们的低位双字:

// This sequence transfers control to location "IsGreater" if
// DwordValue > DwordValue2\. It transfers control to "IsLess" if
// DwordValue < DwordValue2\. It falls through to the instruction
// following this sequence if DwordValue = DwordValue2.
// To test for inequality, change the "IsGreater" and "IsLess"
// operands to "NotEqual" in this code.

        ldr x0, [fp, #DwordValue+8]   // Get HO dword.
        ldr x1, [fp, #DwordValue2 + 8]
        cmp x0, x1
        bgt IsGreater
        blt IsLess

        ldr x0, [fp, #DwordValue+0]   // If HO qwords equal,
        ldr x1, [fp, #DwordValue2 + 0] // then we must compare
        cmp x0, x1                    // the LO dwords.
        bgt IsGreater
        blt IsLess

// Fall through to this point if the two values are equal.

比较无符号值时,使用 bhi 和 blo 指令替代 bgt 和 blt。

你可以通过以下示例合成任何比较操作,这些示例演示了有符号比较;如果你想进行无符号比较,只需将 bhi、bhs、blo 和 bls 分别替换为 bgt、bge、blt 和 ble 即可。以下每个示例都假设有以下声明:

locals  lcl
oword   OW1
oword   OW2
byte    stkSpace, 64
endl    lcl

以下代码实现了一个 128 位测试,用来判断 OW1 是否小于 OW2(有符号)。如果 OW1 小于 OW2,控制会跳转到 IsLess 标签。如果条件不成立,控制会继续执行下一个语句(标签 NotLess):

 ldr x0, [fp, #OW1 + 8]   // Gets HO dword
    ldr x1, [fp, #OW2 + 8]
    cmp x0, x1
    bgt NotLess
    blt IsLess

    ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
    ldr x1, [fp, #OW2 + 0]   // dwords are equal.
    cmp x0, x1
    blt IsLess
NotLess:

这是一个 128 位的测试,用来判断 OW1 是否小于或等于 OW2(有符号)。如果条件成立,代码会跳转到 IsLessEQ 标签:

 ldr x0, [fp, #OW1 + 8]   // Gets HO dword
    ldr x1, [fp, #OW2 + 8]
    cmp x0, x1
    bgt NotLessEQ
    blt IsLessEQ

    ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
    ldr x1, [fp, #OW2 + 0]   // dwords are equal.
    cmp x0, x1
    ble IsLessEQ
NotLessEQ:

这是一个 128 位的测试,用来判断 OW1 是否大于 OW2(有符号)。如果条件成立,程序将跳转到 IsGtr 标签:

 ldr x0, [fp, #OW1 + 8]   // Gets HO dword
    ldr x1, [fp, #OW2 + 8]
    cmp x0, x1
    bgt IsGtr
    blt NotGtr

    ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
    ldr x1, [fp, #OW2 + 0]   // dwords are equal.
    cmp x0, x1
    bgt IsGtr
NotGtr:

以下是一个 128 位的测试,用来判断 OW1 是否大于或等于 OW2(有符号)。如果条件成立,代码将跳转到 IsGtrEQ 标签:

 ldr x0, [fp, #OW1 + 8]   // Gets HO dword
    ldr x1, [fp, #OW2 + 8]
    cmp x0, x1
    bgt IsGtrEQ
    blt NotGtrEQ

    ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
    ldr x1, [fp, #OW2 + 0]   // dwords are equal.
    cmp x0, x1
    bge IsGtrEQ
NotGtrEQ:

这是一个 128 位的测试,用来判断 OW1 是否等于 OW2(有符号或无符号)。如果 OW1 等于 OW2,代码会跳转到 IsEqual 标签。如果它们不相等,则会继续执行下一个指令:

 ldr x0, [fp, #OW1 + 8]   // Gets HO dword
    ldr x1, [fp, #OW2 + 8]
 cmp x0, x1
    bne NotEqual

    ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
    ldr x1, [fp, #OW2 + 0]   // dwords are equal.
    cmp x0, x1
    beq IsEqual
NotEqual:                  // Fall through to here if not equal.

以下是一个 128 位的测试,用来判断 OW1 是否不等于 OW2(有符号或无符号)。如果 OW1 不等于 OW2,代码会跳转到 IsNotEqual 标签。如果它们相等,则会继续执行下一个指令:

ldr x0, [fp, #OW1 + 8]   // Gets HO dword
ldr x1, [fp, #OW2 + 8]
cmp x0, x1
bne NotEqual

ldr x0, [fp, #OW1 + 0]   // Fall through to here if the HO
ldr x1, [fp, #OW2 + 0]   // dwords are equal.
cmp x0, x1
bne NotEqual

// Fall through to here if they are equal.

为了将前面的代码推广到大于 128 位的对象,首先比较对象的高位双字(HO 双字),然后逐步向低位双字(LO 双字)比较,前提是对应的双字相等。以下示例比较两个 256 位的值,判断第一个值是否小于或等于(无符号)第二个值:

 locals cmp256
        dword  Big1, 4
        dword  Big2, 4
        endl   cmp256

         .
         .
         .
        ldr  x0, [fp, #Big1 + 24]
        ldr  x1, [fp, #Big2 + 24]
        cmp  x0, x1
        blo  isLE
        bhi  notLE

        ldr  x0, [fp, #Big1 + 16]
        ldr  x1, [fp, #Big2 + 16]
        cmp  x0, x1
        blo  isLE
        bhi  notLE

 ldr  x0, [fp, #Big1 + 8]
        ldr  x1, [fp, #Big2 + 8]
        cmp  x0, x1
        blo  isLE
        bhi  notLE

        ldr  x0, [fp, #Big1 + 0]
        ldr  x1, [fp, #Big2 + 0]
        cmp  x0, x1
        bnls notLE
isLE:

 `Code to execute if Big1 <= Big2`
         .
         .
         .
notLE:

 `Code to execute if Big1 > Big2`

假设,在 notLE 标签之前有一个分支,用于跳过如果 Big1 > Big2 时需要执行的代码。

8.1.4 乘法

尽管 64 × 64 位乘法(或较小的变体)通常足够使用,但有时你可能需要乘以更大的值。使用 ARM 的单操作数 umul 和 smul 指令来执行扩展精度乘法操作,采用手动乘法时相同的技巧。

你可能会使用手动的方法执行多位数乘法,如图 8-3 所示。

图 8-3:多位数乘法

ARM 以相同的方式执行扩展精度乘法,但使用字和双字而不是数字,如图 8-4 所示。

图 8-4:扩展精度乘法

执行扩展精度乘法时,请记住,你还必须同时执行扩展精度加法。将所有部分积相加需要多次加法操作。

到目前为止,你所看到的 umul 和 smul 指令将两个 n 位操作数(32 位或 64 位)相乘,产生 n 位结果,忽略任何溢出。你不能轻易使用这些指令进行多精度乘法运算。幸运的是,ARM CPU 提供了两组扩展精度乘法指令,可以完成这项工作:一组用于 32 × 32 乘法(产生 64 位结果),另一组用于 64 × 64 乘法(产生 128 位结果)。

以下是产生 64 位结果的指令:

smull  Xdest, Wsrc1, Wsrc2        // Xdest = Wsrc1 * Wsrc2 (signed long)
umull  Xdest, Wsrc1, Wsrc2        // Xdest = Wsrc1 * Wsrc2 (unsigned long)

smnegl Xdest, Wsrc1, Wsrc2        // Xdest = -(Wsrc1 * Wsrc2)
umnegl Xdest, Wsrc1, Wsrc2        // Xdest = -(Wsrc1 * Wsrc2)

smaddl Xdest, Wsrc1, Wsrc2, Xsrc3  // Xdest = (Wsrc1 * Wsrc2) + Xsrc3
umaddl Xdest, Wsrc1, Wsrc2, Xsrc3  // Xdest = (Wsrc1 * Wsrc2) + Xsrc3

smsubl Xdest, Wsrc1, Wsrc2, Xsrc3  // Xdest = (Wsrc1 * Wsrc2) - Xsrc3
umsubl Xdest, Wsrc1, Wsrc2, Xsrc3  // Xdest = (Wsrc1 * Wsrc2) - Xsrc3

smull(带符号长整型乘法)和 umull(无符号长整型乘法)指令将 32 位寄存器相乘,产生 64 位结果,并将结果存储在 64 位目标寄存器中。smnegl 和 umnegl 也会将两个 32 位值相乘,但在将 64 位结果存储到目标寄存器之前会对结果取反。

smaddl/umaddl 和 smsubl/umsubl 指令将它们的 32 位操作数相乘,产生 64 位结果,然后在将结果存储到 64 位目标寄存器之前,再将一个 64 位寄存器加或减到结果中。例如,你可以使用 smaddl/umaddl 指令来将 C × B 相乘,并同时加上 D × A,如 图 8-4 所示。

32 × 32 乘法指令看似有用,但实际上不如它们看起来那样有用,因为现有的 mxxx 指令将接受 64 位操作数(产生 64 位结果)。你可以轻松地将一个 32 位值零扩展或符号扩展到 64 位寄存器,并使用标准乘法指令实现与长整型乘法指令相同的结果。

你可以使用 32 位长整型乘法指令来合成更大的乘法(例如,128 位乘法)。然而,ARM 提供了两条更适合此操作的附加指令:smulh 和 umulh(带符号和无符号高位乘法):

smulh  Xdest, Xsrc1, Xsrc2   // Xdest = (Xsrc1 * Xsrc2) asr 64
umulh  Xdest, Xsrc1, Xsrc2   // Xdest = (Xsrc1 * Xsrc2) lsr 64

这些指令将两个 64 位源操作数相乘,并将 128 位结果的高 64 位存储到目标寄存器中。标准的 mul 指令产生结果的低 64 位,因此在 mul 和 smulh/umulh 指令之间,你可以计算出完整的 128 位结果:

// Multiply X0 × X1, producing a 128-bit result in X3:X2
// (unsigned).

    mul    x2, x0, x1
    umulh  x3, x0, x1

对于带符号的乘法,只需将 smulh 替换为 umulh 即可。

要将更大的值相乘,可以使用 mul、umulh 和 smulh 指令来实现 图 8-4 所示的算法。示例 8-1 展示了如何使用 64 位指令将两个 128 位值相乘(产生一个 256 位的结果)。

// Listing8-1.S
//
// 128-bit multiplication

#include "aoaa.inc"

            .code
            .extern printf

ttlStr:     wastr   "Listing 8-1"

fmtStr1:    .ascii  "%016lx_%016lx * %016lx_%016lx  = \n"
            wastr   "    %016lx_%016lx_%016lx_%016lx\n"

op1:        .qword  0x10001000100010001000100010001000
op2:        .qword  0x10000000000000000000000000000000

// Return program title to C++ program:

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// mul128
//
// Multiplies two unsigned 128-bit values passed on the stack by
// doing a 128x128-bit multiplication, producing a 256-bit
// result
//
// Stores result to location pointed at by X8

          ❶ proc    mul128

            args    a128
            qword   m128.mp    // Multiplier
            qword   m128.mc    // Multiplicand
            enda    a128

            locals  m128
            qword   m128.saveX01
            qword   m128.saveX23
            qword   m128.saveX45
            qword   m128.saveX67
            byte    stkSpace, 64
            endl    m128

            enter   m128.size

          ❷ stp     x0, x1, [fp, #m128.saveX01]  // Preserve
            stp     x2, x3, [fp, #m128.saveX23]  // these
            stp     x4, x5, [fp, #m128.saveX45]  // register
            stp     x6, x7, [fp, #m128.saveX67]  // values.

// Load operands into registers:

          ❸ ldr     x0, [fp, #m128.mp]
            ldr     x1, [fp, #m128.mp+8]
 ldr     x2, [fp, #m128.mc]
            ldr     x3, [fp, #m128.mc+8]

// X5:X4 = X0 * X2

            mul     x4, x0, x2
            umulh   x5, x0, x2

// X6:X7 = X1 * X2, then X5 = X5 + X7 (and save carry for later):

            mul     x7, x1, x2
            umulh   x6, x1, x2
            adds    x5, x5, x7

// X7 = X0 * X3, then X5 = X5 + X7 + C (from earlier):

            mul     x7, x0, x3
            adcs    x5, x5, x7
            umulh   x7, x0, x3
            adcs    x6, x6, x7  // Add in carry from adcs earlier.

// X7:X2 = X3 * X1

            mul     x2, x3, x1
            umulh   x7, x3, x1

            adc     x7, x7, xzr  // Add in C from previous adcs.
            adds    x6, x6, x2   // X6 = X6 + X2
            adc     x7, x7, xzr  // Add in carry from adds.

// X7:X6:X5:X4 contains 256-bit result at this point:

          ❹ stp     x4, x5, [x8]      // Save result to location
            stp     x6, x7, [x8, #16] // pointed at by X8.

            ldp     x0, x1, [fp, #m128.saveX01] // Restore
            ldp     x2, x3, [fp, #m128.saveX23] // saved
            ldp     x4, x5, [fp, #m128.saveX45] // registers.
            ldp     x6, x7, [fp, #m128.saveX67]
            leave
            endp    mul128

// Here is the asmMain function:

            proc    asmMain, public
            locals  am
            oword   product
            byte    stkSpace, 128
            endl    am

            enter   am.size

            str     xzr, [fp, #product]

// Test the mul128 function:

          ❺ lea     x2, op1
            ldp     x0, x1, [x2]
            stp     x0, x1, [sp]

            lea     x2, op2
            ldp     x0, x1, [x2]
            stp     x0, x1, [sp, #16]
            add     x8, fp, #product
            bl      mul128

// Print the result:

          ❻ lea     x0, op1         // Note: display HO
            ldr     x1, [x0, #8]    // dwords first so the
            mstr    x1, [sp]        // values appear normal.

            ldr     x2, [x0]
            mstr    x2, [sp, #8]

            lea     x0, op2
            ldr     x3, [x0, #8]
            mstr    x3, [sp, #16]
            ldr     x4, [x0]
            mstr    x4, [sp, #24]

            ldr     x5, [fp, #product+24]
            mstr    x5, [sp, #32]

            ldr     x6, [fp, #product+16]
            mstr    x6, [sp, #40]

            ldr     x7, [fp, #product+8]
            mstr    x7, [sp, #48]

            ldr     x0, [fp, #product]
// Under macOS, all arguments must be on stack for printf,
// under Linux, only eighth argument is on stack.

EightthArg   =       56  // For macOS
//EightthArg =       0   // For Linux

             str     x0, [sp, #EighthArg]

             lea     x0, fmtStr1
             bl      printf

             leave   // Returns to caller
             endp    asmMain

mul128 过程❶将堆栈中传递的两个 128 位值相乘(注意这不符合 ARM ABI)。尽管在 ARM ABI 中 X0 到 X7 是易失的,但此函数很好地保存了这些寄存器❷。代码将堆栈中的两个 128 位值加载到 X1:X0 和 X3:X2 寄存器对❸中。128 位乘法算法如下,程序注释中有详细说明。

该代码将 256 位的结果存储到通过 X8 寄存器传递给此函数的内存位置❹;然后,mul128 函数恢复保存的寄存器并返回给调用者。主程序调用 mul128 ❺并显示结果(以十六进制形式)❻。

这是清单 8-1 的构建命令和输出:

$ ./build Listing8-1
$ ./Listing8-1
Calling Listing8-1:
1000100010001000_1000100010001000 * 1000000000000000_0000000000000000  =
    0100010001000100_0100010001000100_0000000000000000_1000100010001000
Listing8-1 terminated

该代码仅适用于无符号操作数。要乘以两个有符号值,必须将 umulh 指令更改为 smulh。

清单 8-1 相对简单,因为可以将部分积保存在不同的寄存器中。如果需要将更大的值相乘,就需要将部分积保存在临时(内存)变量中。除此之外,清单 8-1 使用的算法可以推广到任何数量的字。

8.1.5 除法

你不能使用 sdiv 和 udiv 指令合成通用的 n 位 / m 位除法操作。通用的扩展精度除法需要一系列移位和减法操作,这需要很多指令并且执行速度较慢。本节介绍了扩展精度除法的算法。

与乘法类似,理解计算机如何执行除法的最佳方法是研究你可能被教导的手工长除法方法。考虑你手动将 3,456 除以 12 的步骤,如图 8-5 所示。

图 8-5:手动逐位除法操作

在二进制下,算法更容易,因为你不需要在每一步猜测 12 多少次能进入余数,也不需要将 12 乘以你的猜测来得到要减去的数值。在二进制算法的每一步,除数要么将余数整除一次,要么完全不能整除。例如,图 8-6 展示了如何用二进制将 27 除以 3(即将 11011 除以 11)。

图 8-6:二进制的长除法

以下算法以一种同时计算商和余数的方式实现了二进制除法操作:

Quotient := Dividend;
Remainder := 0;
for i := 1 to NumberBits do

    Remainder:Quotient := Remainder:Quotient LSL 1;
    if Remainder >= Divisor then

        Remainder := Remainder - Divisor;
        Quotient := Quotient + 1;

    endif
endfor

NumberBits 是余数、商、除数和被除数变量中的位数。LSL 是左移操作符。语句 Quotient := Quotient + 1; 将商的最低有效位(LO bit)设置为 1,因为该算法之前将商左移了 1 位。清单 8-2 实现了这个算法。

// Listing8-2.S
//
// 128-bit by 128-bit division

#include "aoaa.inc"

            .data

// op1 is a 128-bit value. Initial values were chosen
// to make it easy to verify the result.

op1:        .qword   0x2000400060008000A000C000E0001000
op2:        .qword   2
op3:        .qword   0xEEEECCCCAAAA88886666444422221111
result:     .qword   0
remain:     .qword   0

            .code
            .extern  printf

ttlStr:     wastr    "Listing 8-2"
fmtStr1:    .ascii   "quotient  = "
            wastr    "%016lx_%016lx\n"

fmtStr2:    .ascii   "remainder = "
            wastr    "%016lx_%016lx\n"

fmtStr3:    .ascii   "quotient (2)  = "
            wastr    "%016lx_%016lx\n"

// Return program title to C++ program:

            proc     getTitle, public
            lea      x0, ttlStr
            ret
            endp     getTitle

// div128
//
// This procedure does a general 128 / 128 division operation
// using the following algorithm (all variables are assumed
// to be 128-bit objects):
//
// Quotient := Dividend
// Remainder := 0
// for i := 1 to NumberBits do
//
//  Remainder:Quotient := Remainder:Quotient SHL 1
//  if Remainder >= Divisor then
//
//      Remainder := Remainder - Divisor
//      Quotient := Quotient + 1
//
// endif
// endfor
//
// Data passed:
//
// 128-bit dividend, by reference in X0
// 128-bit divisor, by reference in X1
//
// Data returned:
//
// Pointer to 128-bit quotient in X8
// Pointer to 128-bit remainder in X9

          ❶ proc    div128

#define remainderL  x10
#define remainderH  x11
#define dividendL   x12
#define dividendH   x13
#define quotientL   dividendL
#define quotientH   dividendH
#define divisorL    x14
#define divisorH    x15

            locals  d128
            dword   saveX0
            qword   saveX1011
            qword   saveX1213
            qword   saveX1415
            byte    stkSpace, 64
            endl    d128

quotient    =       dividend        // Alias to dividend

            enter   d128.size       // Set up activation record.

// Preserve registers div128 modifies:

          ❷ str     x0, [fp, #saveX0]
            stp     x10, x11, [fp, #saveX1011]
            stp     x12, x13, [fp, #saveX1213]
            stp     x14, x15, [fp, #saveX1415]

// Initialize remainder with 0:

          ❸ mov     remainderL, #0
            mov     remainderH, #0

// Copy the dividend to local storage:

            ldp     dividendL, dividendH, [x0]

// Copy the divisor to local storage:

            ldp     divisorL, divisorH, [x1]

            mov     w0, #128           // Count off bits in W0.

// Compute Remainder:Quotient := Remainder:Quotient LSL 1
//
// Note: adds x, x, x is equivalent to lsl x, x, #1
//       adcs x, x, x is equivalent to rol x, x, #1
//                    (if rol existed)
//
// The following four instructions perform a 256-bit
// extended-precision shift (left) dividend through
// remainder:

repeatLp:   adds    dividendL, dividendL, dividendL
            adcs    dividendH, dividendH, dividendH
            adcs    remainderL, remainderL, remainderL
            adc     remainderH, remainderH, remainderH

// Do a 128-bit comparison to see if the remainder
// is greater than or equal to the divisor:

            cmp     remainderH, divisorH
            bhi     isGE
            blo     notGE

            cmp     remainderL, divisorL
            bhi     isGE
            blo     notGE

// Remainder := Remainder - Divisor

isGE:       subs    remainderL, remainderL, divisorL
            sbc     remainderH, remainderH, divisorH

// Quotient := Quotient + 1:

            adds    quotientL, quotientL, #1
            adc     quotientH, quotientH, xzr

// Repeat for 128 bits:

notGE:      subs    w0, w0, #1
            bne     repeatLp

// Okay, copy the quotient (left in the Dividend variable)
// and the remainder to their return locations:

          ❹ stp     quotientL, quotientH, [x8]
            stp     remainderL, remainderH, [x9]

// Restore the registers div128 modified:

          ❺ ldr     x0, [fp, #saveX0]
            ldp     x10, x11, [fp, #saveX1011]
            ldp     x12, x13, [fp, #saveX1213]
            ldp     x14, x15, [fp, #saveX1415]
            leave   // Return to caller.
            endp    div128

// Here is the asmMain function:

            proc    asmMain, public

            locals  am
            byte    am.stkSpace, 64
            endl    am

            enter   am.size         // Sets up activation record

// Test the div128 function:

          ❻ lea     x0, op1
            lea     x1, op2
            lea     x8, result
            lea     x9, remain
            bl      div128

// Print the results:

            ldr     x1, [x8, #8]    // X8 still points at result.
            mstr    x1, [sp]
            ldr     x2, [x8]
            mstr    x2, [sp, #8]

            lea     x0, fmtStr1
            bl      printf

            lea     x9, remain      // Assume printf munged X9,
            ldr     x1, [x9, #8]    // must reload.
            mstr    x1, [sp]
            ldr     x2, [x9]
            mstr    x2, [sp, #8]

            lea     x0, fmtStr2
            bl      printf

// Test the div128 function (again):

            lea     x0, op3
            lea     x1, op2
            lea     x8, result
            lea     x9, remain
            bl      div128

// Print the results:

            ldr     x1, [x8, #8]    // X8 still points at result.
            mstr    x1, [sp]
            ldr     x2, [x8]
            mstr    x2, [sp, #8]

            lea     x0, fmtStr3
            bl      printf

            lea     x9, remain      // Must reload
            ldr     x1, [x9, #8]    // (because of printf).
            mstr    x1, [sp]
            ldr     x2, [x9]
            mstr    x2, [sp, #8]

            lea     x0, fmtStr2
            bl      printf

            leave   // Returns to caller
            endp    asmMain

div128 函数 ❶ 是一个 128 × 128 位的除法操作,它同时生成商和余数。与之前给出的扩展精度乘法不同,这个函数通过引用传递其参数(在 X0 和 X1 中),而不是通过栈上的值。它将 128 位的商存储在 X8 指向的位置,将余数存储在 X9 指向的位置。与乘法代码一样,div128 函数 ❷ 会保存它修改的所有易失性寄存器。

接下来是除法算法 ❸,如程序注释中所描述的。代码将商和余数存储起来 ❹,然后恢复已保存的寄存器 ❺。主程序 ❻ 通过一对调用来演示 div128 函数,并包含显示结果的代码。

以下是构建命令和程序输出:

$ ./build Listing8-2
$ ./Listing8-2
Calling Listing8-2:
quotient  = 1000200030004000_5000600070000800
remainder = 0000000000000000_0000000000000000
quotient (2)  = 7777666655554444_3333222211110888
remainder = 0000000000000000_0000000000000001
Listing8-2 terminated

该代码未检查除以 0 的情况(如果尝试除以 0,它会产生商 0xFFFF_FFFF_FFFF_FFFF)。它仅处理无符号值,并且非常慢,比 sdiv/udiv 指令差几个数量级。为了处理除以 0 的情况,在执行此代码之前请检查除数是否为 0,并在除数为 0 时返回适当的错误代码。处理有符号值时,请注意符号,取操作数的绝对值,执行无符号除法,然后通过设置结果为负值来修正符号(如果操作数符号不同)。

8.1.6 取反

neg 指令没有提供通用的扩展精度形式。然而,取反等同于从 0 中减去一个值,因此可以通过使用 subs 和 sbcs 指令轻松模拟扩展精度的取反。

以下代码提供了一种简单的方法,通过从 0 中减去一个值来取反(320 位值),使用扩展精度减法:

ldr  x0, [fp, #value320]
subs x0, xzr, x0
str  x0, [fp, #value320]

ldr  x0, [fp, #value320 + 8]
sbcs x0, xzr, x0
str  x0, [fp, #value320 + 8]

ldr  x0, [fp, #value320 + 16]
sbcs x0, xzr, x0
str  x0, [fp, #value320 + 16]

ldr  x0, [fp, #value320 + 24]
sbcs x0, xzr, x0
str  x0, [fp, #value320 + 24]

ldr  x0, [fp, #value320 + 32]
sbcs x0, xzr, x0
str  x0, [fp, #value320 + 32]

你可以通过使用我为扩展精度减法提供的方案,将此算法扩展到任意数量的位(或缩减为更少的位)。

8.1.7 AND

执行 n 字节的 AND 操作很简单:只需对两个操作数之间的相应字节进行 AND 运算,并保存结果。例如,要对所有 128 位长的操作数执行 AND 操作,可以使用以下代码:

ldp x0, x1, [fp, #source1]
ldp x2, x3, [fp, #source2]
and x2, x2, x0
and x3, x3, x1
stp x2, x3, [fp, #dest]

要将此技术扩展到任意数量的双字(dword),在操作数中对相应的双字执行逻辑与操作(AND)。

在执行 AND 序列后测试标志时,请记住,and 指令只会为 AND 序列中的特定部分设置标志。如果将最后一个 and 转换为 ands 指令,它会正确设置 N 标志,但不会正确设置 Z 标志。要设置 Z 标志(表示整个 128 位结果为 0),可以使用 ccmp(条件比较)指令来测试 ands 指令中的 Z 标志,并将 X2 与 0 进行比较(请参见第 6.1.4 节“条件指令”,在第 297 页):

ldp  x0, x1, [fp, #source1]
ldp  x2, x3, [fp, #source2]
and  x2, x2, x0
ands x3, x3, x1
stp  x2, x3, [fp, #dest]
ccmp x2, #0, 0b0100, eq   // Sets Z if X3 == 0 && X2 == 0

如果在这个序列后你需要测试 N 标志和 Z 标志,可以考虑使用 tbz/tbnz 指令来测试寄存器 X3 中的 HO 位,它包含符号位。

8.1.8 或运算

多字节的逻辑或操作与多字节的与操作执行方式相同:你将两个操作数中对应的字节进行或运算。例如,要对两个 256 位的值进行逻辑或操作,可以使用以下代码:

ldp x0, x1, [fp, #source1]
ldp x2, x3, [fp, #source1 + 16]
ldp x4, x5, [fp, #source2]
ldp x6, x7, [fp, #source2 + 16]

orr x0, x0, x4
orr x1, x1, x5
orr x2, x2, x6
orr x3, x3, x7

stp x0, x1, [fp, #dest]
stp x2, x3, [fp, #dest+16]

记住,orr 指令不会影响任何标志(并且没有 orrs 指令)。如果你需要在扩展精度的或操作后测试零标志,必须将所有结果双字与 0 进行比较。

你还可以使用 Vn寄存器执行扩展精度的逻辑操作,最多一次处理 128 位。有关更多详细信息,请参阅第 11.13 节“在实际程序中使用 SIMD 指令”,见第 699 页。

8.1.9 异或

与其他逻辑操作一样,扩展精度的 XOR 操作会对两个操作数中对应的字节进行异或运算,得到扩展精度的结果。以下代码序列作用于两个 128 位的操作数,计算它们的异或,并将结果存储到一个 128 位的变量中:

ldp x0, x1, [fp, #source1]
ldp x2, x3, [fp, #source2]
eor x2, x2, x0
eor x3, x3, x1
stp x2, x3, [fp, #dest]

前一节中关于零标志的评论也适用于这里,以及关于 Vn寄存器的评论。

8.1.10 非操作

mvn 指令会反转指定操作数的所有位。通过在所有受影响的操作数上执行 mvn 指令,执行扩展精度的 NOT 操作。例如,要对 X1:X0 中的值执行 128 位的 NOT 操作,可以执行以下指令:

mvn x0, x0
mvn x1, x1

如果你执行两次 mvn 指令,你会得到原始值。同样,用所有 1 的值(例如 0xFF、0xFFFF、0xFFFF_FFFF 或 0xFFFF_FFFF_FFFF_FFFF)对某个值进行异或运算,效果与 mvn 指令相同。

8.1.11 移位操作

ARM 上的扩展精度移位操作存在一些问题。传统上,实现扩展精度移位的方法是将一个位从一个寄存器移到进位标志中,然后将该进位位旋转到另一个寄存器中。不幸的是,ARM 没有提供这样的指令,因此需要采用不同的方法。

具体的实现方法取决于以下两个因素,详见下面的子章节:需要移位的位数和移位的方向。

8.1.11.1 左移

128 位的 lsl(逻辑左移)呈现出图 8-7 所示的形式。

图 8-7:128 位左移操作

为了使用机器指令实现这一操作,你必须首先将 LO 双字左移(例如,使用 lsls 指令),并从第 63 位捕获输出(幸运的是,进位标志会为我们做到这一点)。接着,将该位移入 HO 双字的 LO 位,同时将所有其他位左移(并通过进位标志捕获输出)。没有指令专门将进位标志旋转到寄存器中,但你可以使用神奇的指令 adc/adcs 来实现此功能,只要你提供适当的操作数。

记住,左移操作等同于乘以 2。将一个值加到它自身上,正是乘以 2 的定义。因此,lsls 和 adds 指令都可以将操作数左移,并将溢出位移入进位标志。为了让 adds 像左移操作一样工作,你必须在两个源操作数位置提供相同的操作数:

adds x0, x0, x0  // Same as lsl x0, x0, #1

adcs 指令(使用相同的操作数)也将所有位左移一个位置,并将进位标志移入第 0 位(同时将 HO 位移入进位标志,操作结束时如此)。这实际上是一个单比特的进位左旋操作,如图 8-8 所示。

图 8-8:通过进位左旋操作

你可以使用 adds 和 adcs 指令实现 128 位的左移。例如,要将 X1:X0 中的 128 位数据左移一位,可以使用以下指令:

adds x0, x0, x0
adcs x1, x1, x1

adds 指令将 0 移入 128 位操作数的第 0 位,并将第 63 位移入进位标志。接着,adcs 指令将进位标志移入第 64 位,并将第 127 位移入进位标志,从而得到你想要的结果,如图 8-9 所示。

图 8-9:使用 adds/adcs 指令进行扩展精度左移

使用这种技巧,你只能一次性将扩展精度值左移 1 位。你无法通过寄存器将扩展精度操作数左移多个比特,也无法在使用此技术时指定大于 1 的常量值。

要对大于 128 位的操作数进行左移,使用额外的 adcs 指令。扩展精度左移操作总是从最低有效双字开始,每个随后的 adcs 指令操作下一个更高位的双字。例如,要对一个内存位置执行 192 位的左移操作,你可以使用以下指令:

adds x0, x0, x0
adcs x1, x1, x1
adcs x2, x2, x2

如果你需要将数据左移 2 位或更多位,可以重复前面的指令序列以实现固定次数的左移,或者将这些指令放入循环中,以便按需重复执行。例如,以下代码将 X0、X1 和 X2 中的 192 位值左移 W3 中指定的位数:

ShiftLoop:
    adds x0, x0, x0
    adcs x1, x1, x1
    adcs x2, x2, x2
    subs w3, w3, #1
    bne  ShiftLoop

这个多位移位的唯一问题是,当移位超过几个位时,它可能会运行得比较慢。通常,我们说这个算法是 O(n),意味着运行时间与我们左移的位数成正比。

一条可以同时移位多个位的指令,就像 lsl 指令那样,将有助于解决这个问题。如果存在 rol 指令,你可以用它将 X1:X0 中的 128 位值向左移 8 位:

rol     x2, x0, #8       // Shift HO 8 bits into LO 8
and     x2, x2, #0xFF    // bits and clear other bits.
lsl     x0, x0, #8       // Shift X0 8 bits.
lsl     x1, x1, #8       // Shift X1 8 bits.
orr     x1, x1, x2       // Merge in LO 8 bits.

不幸的是,ARM CPU 的指令集没有 rol 指令;不过,你可以使用 ror 指令来做任何 rol 指令可以做的事。对于范围在 1–63 之间的任何位移,rol(n) 等同于 ror((64 - n) % 64),其中 rox(n) 意味着“将值左/右旋转 n 位”。对于 rol(0) 的特殊情况,ror(0) ((64 - 0) % 64) 是 0,也会将值旋转 0 位。因此,你可以用这个来替换之前的无法编译代码:

ror     x2, x0, #64-8    // Shift HO 8 bits into LO 8
and     x2, x2, #0xFF    // bits and clear other bits.
lsl     x0, x0, #8       // Shift X0 8 bits.
lsl     x1, x1, #8       // Shift X1 8 bits.
orr     x1, x1, x2       // Merge in LO 8 bits.

n 大于 2 或 3 时,这个序列的执行速度比之前给出的 adds/adcs 循环要快得多。

图 8-10 到 图 8-14 显示了此扩展精度左移操作的过程。

图 8-10:使用 ror 进行的扩展精度左移,移位前

在 图 8-11 中,算法会临时复制从第 0 位到第 63 位的位并将值左移 8 位。

图 8-11:步骤 1:制作临时副本并移位

图 8-12 显示了将原始值左移 8 位(这会清除 LO 位)并通过与操作清除 HO 临时位。

图 8-12:步骤 2:移位并清除位

图 8-13 显示了临时 dword 和 HO dword 的合并(或操作)。

图 8-13:步骤 3:合并临时和 HO dword

图 8-14 显示了移位后的结果。

图 8-14:步骤 4:移位后

要实现一个可变的扩展精度左移操作,代码需要生成一个位掩码来清除 LO 位(之前代码中的 and 指令)。事实证明,你可以通过以下代码生成一个 n 位移的掩码:

mov x3, #1
lsl x3, x3, x4   // Assume X4 contains the shift count.
sub x3, x3, #1   // Generates 1 bits in positions 0 to (n-1)
and x2, x2, x3   // Clears unused bits of X2

这里的技巧是 lsl(n) 生成 2n*,然后,2*n - 1 是从第 0 位到第 n - 1 位的全 1 位。 ##### 8.1.11.2 右移和算术右移

不幸的是,像使用 adds/adcs 指令的技巧并不能让你执行 通过进位右旋 操作(将所有位通过进位向右移,并将原始进位重新移入 HO 位)。因此,要执行扩展精度右移(或算术右移),你必须再次使用 ror 指令。以下是一个示例,将 128 位值从 X1:X0 右移 8 位:

ror x2, x1, #8            // Shifts bits 64-71 into HO
and x2, x2, #0xFF << 56   // 8 bits and clears bits 64-119
lsr x1, x1, #8            // Shifts X1 8 bits
lsr x0, x0, #8            // Shifts X0 8 bits
orr x0, x0, x2            // Merges in bits 56-63

该扩展精度的算术右移操作的代码类似:

ror x2, x1, #8            // Shifts bits 64-71 into HO
and x2, x2, #0xFF << 56   // 8 bits and clears bits 64-119
asr x1, x1, #8            // Arithmetic shift X1 8 bits
lsr x0, x0, #8            // Shifts X0 8 bits
orr x0, x0, x2            // Merges in bits 56-63

在这种情况下,你将 asr 指令替换了 HO 双字上的 lsr 指令。请注意,LO 双字仍然使用 lsr 指令;lsr 是必要的,它将 0 填充到 HO 位,以便 orr 指令能够正确合并从 HO 双字移出的位。

作为最后一个例子,以下是一个 192 位的算术右移操作,将 X2:X1:X0 中的位向右移动 4 位:

ror x3, x2, #4            // Temp copy holding bits 128-131
And x3, x3, #0xF << 60    // Clears all but HO 4 bits of temp
asr x2, x2, #4            // Arithmetic shift right X2 4 bits
ror x4, x2, #4            // Temp (2) copy holding bits 64-67
And x4, x4, #0xF << 60    // Clears all but HO 4 bits of temp2
lsr x2, x2, #4            // Shifts the original 3 dwords 4 bits
lsr x1, x1, #4
lsr x0, x0, #4
orr x1, x1, x3            // Merges in bits 124-127
orr x0, x0, x4            // Merges in bits 60-63

Neon 指令允许你将 128 位值左右移动;详细信息请参见第十一章。

8.2 对不同大小操作数的操作

有时,你可能需要对一对不同大小的操作数进行计算(混合大小混合模式算术运算)。例如,你可能需要将一个字与一个双字相加,或者从一个字值中减去一个字节值。为了做到这一点,将较小的操作数扩展到较大操作数的大小,然后对两个相同大小的操作数进行操作。对于符号操作数,将较小的操作数符号扩展到与较大操作数相同的大小;对于无符号值,将较小的操作数零扩展。这适用于任何运算。

以下例子演示了字节变量、半字变量和双字变量的加法操作:

locals  lcl
byte    var1
hword   var2
align   3
dword   var3
endl    lcl
 .
 .
 .
// Unsigned addition (8-bit + 16-bit addition
// producing a 16-bit result):

ldrb    w0, [fp, #var1] // Zero-extends byte to 32 bits
ldrh    w1, [fp, #var2] // Zero-extends hword to 32 bits
add     w0, w0, w1      // Adds 32 bits
strh    w0, [fp, #var2] // Store LO 16 bits in var2.

// Signed addition (8-bit + 16-bit addition
// producing a 16-bit result):

ldrsb   w0, [fp, #var1] // Sign-extends byte to 32 bits
ldrsh   w1, [fp, #var2] // Sign-extends hword to 32 bits
add     w0, w0, w1      // Adds 32 bits
strh    w0, [fp, #var2] // Store LO 16 bits in var2.

在这两种情况下,字节变量被加载到 W0 寄存器中,扩展为 32 位,然后与半字操作数相加(半字操作数也扩展为 32 位)。

所有这些例子都是将字节值与半字值相加。通过将操作数零扩展或符号扩展为相同的大小,你可以轻松地将任何两个不同大小的变量相加。

作为最后一个例子,考虑将一个 8 位的符号值加到一个 qword(128 位)值上:

ldrsb   x0, [fp, #var1] // Sign-extends byte to 64 bits
asr     x1, x0, #63     // Sneaky sign-extend to 128 bits
ldp     x2, x3, [fp, #var3]
adds    x2, x2, x0      // Adds LO dwords
adc     x3, x3, x1      // Adds HO dwords
stp     x2, x3, [fp, #var3]

该代码中的技巧是 asr 指令。此指令通过将 X0 中的符号位复制到 X1 中,从而将 X0 符号扩展到 X1:X0(算术右移 63 位实际上将位 63 复制到位 0–62)。一旦 X0 已被符号扩展到 X1,你就拥有了一个 128 位的值 X1:X0,可以将其与变量 var3 中的 128 位值相加。

本章前面的例子假设不同大小的操作数是内存变量。它们使用了 ldrb/ldrsb 和 ldrh/ldrsh 指令将 8 位和 16 位操作数零扩展或符号扩展为 32 位(也可以通过提供 64 位寄存器将操作数扩展到 64 位)。虽然这些例子没有演示 32 位和 64 位操作数的混合使用,但你也可以使用 ldrsw 指令将 32 位符号扩展为 64 位。

如果你的操作数已经在寄存器中(而不是内存中),你可以使用 uxtb/uxth/uxtw 和 sxtb/sxth/sxtw 指令来零扩展或符号扩展操作数。例如,以下代码将 W0 中的 32 位值符号扩展到 128 位:

// Assume 8-bit value is in W0 and 128-bit value is in X3:X2.
// Add byte in W0 to 128-bit value in X3:X2.

sxtb    x0, w0          // Sign-extends byte to 64 bits
asr     x1, x0, #63     // Sneaky sign-extend to 128 bits
adds    x2, x2, x0      // Adds LO dwords
adc     x3, x3, x1      // Adds HO dwords

当将较小的值加到 32 位或 64 位寄存器中,而不需要将较小的值符号扩展到 128 位或更高时,你可以在算术指令中使用操作数 2 的符号扩展修饰符,将较小的值零扩展或符号扩展到较大的大小:

// Add 8-bit unsigned value in W0 to 32-bit value in W1:

add    w1, w1, w0, uxtb #0

// Add 8-bit signed value in W0 to 32-bit value in W1:

add    w1, w1, w0, sxtb #0

// Add 16-bit unsigned value in W0 to 32-bit value in W1:

add    w1, w1, w0, uxth #0

// Add 16-bit signed value in W0 to 32-bit value in W1:

add    w1, w1, w0, sxth #0

// Add 32-bit unsigned value in W0 to 64-bit value in X1:

add    x1, x1, w0, uxtw #0

// Add 32-bit signed value in W0 to 64-bit value in X1:

add    x1, x1, w0, sxtw #0

要将字节和半字添加到 64 位双字中,只需在此代码中将 W1 寄存器改为 X1。

8.3 继续前进

扩展精度算术在高级语言(HLLs)中是困难的,甚至是无法实现的,但在汇编语言中相对容易。本章描述了 ARM 汇编语言中的扩展精度算术、比较和逻辑运算。最后讨论了混合模式(混合大小)算术,其中操作数的大小不同。

拥有本章提供的信息后,处理大多数高级语言中难以实现的算术和逻辑操作变得轻松。下一章将介绍数值到字符串的转换,当转换大于 64 位的值时,将使用这些扩展精度操作。

8.4 更多信息

  • ARM 指令集缺少的一个算术特性是十进制算术(基数 10),这意味着如果需要,你必须在软件中执行该算术操作。尽管大部分代码是用 C 编写的,但如果你想实现十进制算术,请访问十进制算术网站

  • 唐纳德·克努斯的《计算机程序设计的艺术,第 2 卷:半数值算法》(Addison-Wesley Professional,1997 年)包含了关于十进制算术和扩展精度算术的许多有用信息,尽管该书内容较为通用,描述的是如何在 MIXAL 汇编语言中实现这些操作,而非 ARM 汇编语言。

第九章:9 数字转换

本章讨论了各种数字格式之间的基本转换,包括整数到十进制字符串、整数到十六进制字符串、浮点数到字符串、十六进制字符串到整数、十进制字符串到整数以及实数字符串到浮点数。还涵盖了字符串到数字转换的错误处理以及性能优化。最后,介绍了标准精度转换(适用于 8 位、16 位、32 位和 64 位整数格式)和扩展精度转换(例如,128 位整数/字符串转换)。

在本章中,您将直接在汇编语言中解决问题,而不是像前几章那样从高级语言(HLL)中翻译解决方案。这里的一些示例首先展示了一个使用 HLL 解决问题的代码,然后提供一个优化后的汇编语言解决方案。这应该帮助您学习在不依赖 HLL 的情况下解决汇编语言问题,从而生成更高质量的程序。

9.1 将数字字符串转换为值

到目前为止,本书一直依赖 C 标准库来执行数字输入/输出(将数字数据写入显示屏并从用户读取数字数据)。然而,标准库并不提供扩展精度的数字 I/O 功能(甚至 64 位数字 I/O 都存在问题;本书一直在使用 GCC 扩展的 printf()来执行 64 位数字输出)。因此,现在是时候了解如何在汇编语言中进行数字 I/O 了。

由于大多数操作系统只支持字符或字符串输入和输出,您实际上不会执行数字 I/O。相反,您将编写函数来转换数字值和字符串之间的关系,然后进行字符串 I/O。本节中的示例适用于 64 位(非扩展精度)和 128 位值,但算法是通用的,可以扩展到任何位数。

9.1.1 数字值到十六进制字符串

在本节中,您将学习如何将数值(字节、半字、字、双字等)转换为包含等效十六进制字符的字符串。首先,您需要一个函数,将 4 位的半字节转换为一个单一的 ASCII 字符,字符范围为 '0' 到 '9' 或 'A' 到 'F'。在类似 C 的高级语言(HLL)中,您可以按如下方式编写此函数:

// Assume nibbleIn is in the range 0-15: 

charOut = nibbleIn + '0'; 
if(charOut > '9') charOut = charOut + ('A' - '9' - 1); 

您可以通过将数字值与 '0'(0x30)进行或运算,将任何 0 到 9 范围内的数值转换为其对应的 ASCII 字符。不幸的是,这会将 0xA 到 0xF 范围内的数字值映射到 0x3A 到 0x3F,因此 C 代码会检查是否产生了大于 0x3A 的值,并加上 7('A' – '9' – 1),以生成最终的字符代码,范围为 0x41 到 0x46('A'到'F')。

使用一个将半字节转换为相应 ASCII 字符的函数,你可以通过获取数字中的所有半字节并对每一个半字节调用该函数来转换字节、半字等。然而,由于 ARM 汇编语言程序通常处理的对象不小于字节,因此编写一个将字节值转换为两个 ASCII 字符的函数会更直接、更高效。我们将这个函数称为 btoh(字节到十六进制)。

清单 9-1 展示了一个直接的 btoh 实现。该函数期望 X1 中的单字节值(忽略 X1 中位 8 到 63 的部分),并返回 X1 中位 0 到 15 的两个字符。清单 9-1 通过使用第七章中描述的技术,将 C 算法转换为汇编语言。

// Listing9-1.S 

#include "aoaa.inc"

            proc    btoh_simple 
            and     x1, x1, #0xFF   // Ensure only 8 bits. 
            mov     x0, x1          // Save LO nibble. 

            // Process the HO nibble: 

          ❶ lsr     x1, x1, #4      // Move HO nibble to LO posn. 
            orr     x1, x1, #'0'    // Convert to 0x30 to 0x3F. 
            cmp     x1, #'9'        // See if 0x3A to 0x3F. 
            bls     le9as 
            add     x1, x1, #7      // Convert 0x3A to 0x3F to 
 le9as:                             // 'A' through 'F'. 

            // Process the LO nibble: 

          ❷ and     x0, x0, #0xF    // Strip away HO nibble. 
            orr     x0, x0, #'0'    // Convert to 0x30 to 0x3F. 
            cmp     x0, #'9'        // See if 0x3A to 0x3F. 
            bls     le9bs 
            add     x0, x0, #7      // Convert 0x3A to 0x3F to 
 le9bs:                             // 'A' through 'F'. 
            // Merge the 2 bytes into X1\. 

            orr     x1, x1, x0, lsl #8 
            ret 
            endp    btoh_simple 

该函数返回对应于位 0 到 7 中的 HO 半字节❶的字符,以及对应于位 8 到 15 中的 LO 半字节❷的字符。这是因为你通常会使用该函数构建包含转换后的十六进制值的字符字符串。字符字符串本质上是大端序,最重要的数字出现在最低的内存地址(因此当你打印字符串时,数字会从左到右读取)。将两个字符交换后返回到 X1,允许你通过单条指令将这两个字符作为半字存储到内存中。

你可能会想知道,为什么 btoh_simple 将值传递给 X1 中的 convert,而不是 X0(标准的“第一个参数”位置)。这是为了预见到将来会有一些函数将字符输出到内存缓冲区(字符串)。对于这些基于字符串的函数,X0 将包含缓冲区的地址。

由于清单 9-1 基本上是手动编译的 C/C++代码,因此性能大致与(或比)优化 C/C++编译器处理之前给出的 C 代码要差。为了编写更快的汇编语言代码,你首先需要测量两个函数的性能,以确定哪一个更快。虽然可以使用许多软件工具(性能分析器或分析器)来进行此操作,但我采用了一个简单的解决方案:编写一个主程序,反复调用该函数,然后使用 Unix 时间命令行工具来测量程序运行所需的时间。例如,清单 9-2 展示了这样一个程序。

// Listing9-2.S 

#include "aoaa.inc"

`Include both simple and other code here necessary for a working program.` 

            proc    asmMain, public 

            locals  am                  // Preserve the X20 and 
            dword   saveX20             // X21 registers that 
            dword   saveX21             // this program uses 
            byte    stackspace, 64      // as loop-control 
            endl    am                  // variables. 

            enter   am.size    // Create activation record. 

            str     x20, [fp, #saveX20] // Preserve nonvolatile 
            str     x21, [fp, #saveX21] // registers. 

// Outer loop executes 10,000,000 times: 

            ldr     x20, =10000000 
outer: 

// Inner loop executes 256 times, once for each byte value. 
// It just calls the btoh_*** function and ignores the 
// return value. Do this to measure the speed of the 
// function. 

#define funcToCall btoh_x1 // btoh_x1, btoh2, btoh_nob, or btoh_simple 

            mov     x21, #256 
inner:      add     x1, x20, #-1 
            bl      funcToCall 
            adds    x21, x21, #-1 
            bne     inner 
            adds    x20, x20, #-1 
            bne     outer 

            mov     x1, #0x9a       // Value to test 
            mov     x6, x1          // Save for later. 
            bl      funcToCall 

            // Print btoh_*** return result: 

            and     x2, x1, #0xff   // Print HO nibble first. 
            mstr    x2, [sp, #8] 
            lsr     x3, x1, #8      // Print LO nibble second. 
 mstr    x3, [sp, #16] 
            mov     x1, x6          // Retrieve save value. 
            mstr    x1, [sp] 
            lea     x0, fmtStr1 
            bl      printf 
            ldr     x21, [fp, #saveX21] // Restore nonvolatile 
            ldr     x20, [fp, #saveX20] // registers. 
            leave 
            ret 

            endp    asmMain 

一位高级软件工程师可能会发现这种测量代码执行时间的技术存在几个缺陷。然而,它简单、易于理解和使用,并且不需要任何特殊的软件工具。尽管它所产生的测量结果并不完美,但对于大多数目的来说已经足够。

这是构建命令和示例输出(使用 Unix 时间命令来计时程序运行):

$ ./build Listing9-2 
$ time ./Listing9-2 
Calling Listing9-2: 
Value=9a, as hex=9A 
Listing9-2 terminated 
./Listing9-2  3.49s user 0.01s system 98% cpu 3.542 total 

在我的 Mac mini M1 上,这大约花费了 3.5 秒时间运行。(显然,这会因系统而异;例如,在 Raspberry Pi 3 上,它大约花费了 37 秒。)

如第七章所述,分支指令通常比直线代码运行得更慢。清单 9-2 使用了分支来处理转换后的字符是'0'到'9'或'A'到'F'的情况。我写了一个使用 csel 指令的版本,用于区分这两种情况,方法是在对半字值进行 OR 操作或加上'0'后使用它。代码运行时间为 2.5 秒(在 Mac mini M1 上)。然而,这是通过不保存 X1 和 X2 寄存器实现的。将 X1 和 X2 保存到内存并恢复它们使得执行时间增加到了 4.68 秒。

你刚刚发现了 ARM 汇编代码中的一个大时间瓶颈:访问内存非常慢(而且 ldp/stp 指令比 ldr/str 指令慢得多)。这就是为什么 ARM 定义了非易失性寄存器的原因,这样你就不必在内存中保存某些工作寄存器。然而,保存易失性寄存器有时是值得的,以确保程序的正确性。汇编语言代码可能很快变得复杂,而一个函数覆盖了你在调用代码中忘记保存的寄存器,可能导致长时间的调试过程。一个有缺陷的快速程序永远不如一个运行正常的较慢程序好。

在为 Raspberry Pi 400 编写 32 位 ARM 代码时(这是本系列的第二卷),我发现使用一个 256 元素的查找表(每个元素包含与十六进制值对应的两个字符)比标准算法更快。当我在 64 位 ARM 汇编中尝试这种方法时,运行时间为 4.6 秒。再一次,内存访问(至少在 Apple M1 CPU 上)是昂贵的。在其他系统上,如 Pi 3、4 或 5,你会得到不同的结果。

一旦你能够将单个字节转换为一对十六进制字符,创建字符串并输出到显示器就变得简单了。我们可以为数字中的每个字节调用 btoh(字节到十六进制)函数,并将相应的字符存储在字符串中。通过这个函数,你可以编写 btoStr(字节到字符串)、hwtoStr(半字到字符串)、wtoStr(字到字符串)和 dtoStr(双字到字符串)函数。本章扩展了几个较低级别的函数(btoStr、hwtoStr 和 wtoStr),并使用过程调用来处理较大尺寸的转换(dtoStr)。在第十三章中,我讨论了宏,它们将提供另一种简便的方式来扩展这些函数。

本书的方法是尽量编写快速转换代码。如果你更倾向于节省空间而不是提高速度,请参见以下“减少代码大小”框中的详细信息。

所有的二进制到十六进制字符串函数都将接受两个参数:X1 寄存器中的待转换值,以及一个指向字符串缓冲区的指针,结果将保存在 X0 中。这些函数假设缓冲区足够大,能够容纳字符串结果:btoStr 需要一个 3 字符的缓冲区,hwtoStr 需要一个 5 字符的缓冲区,wtoStr 需要一个 9 字符的缓冲区,dtoStr 需要一个 17 字符的缓冲区。值中的每个字节需要两个字符存放在缓冲区中。除了字符数据外,缓冲区还必须包含 1 字节用于零终止字节。调用者需要负责确保缓冲区足够大。

为了实现这四个十六进制到字符串的函数,我将首先编写四个十六进制到缓冲区的函数。*tobuf 和 *tostr 函数之间有两个不同之处(其中 * 表示根据正则表达式语法的替代:b、hw、w 或 d)。

  • *tobuf 函数不会保留任何寄存器。它们会修改 X0 和 X2 中的值。

  • tobuf 函数将 X0 指向字符串末尾的零终止字节,这通常是有用的;tostr 函数则保留 X0 的值(指向输出缓冲区的第一个字符)。

我还将借此机会介绍另一项汇编语言特性:函数的多个入口点。btobuf、htobuf、wtobuf 和 dtobuf 函数都包含公共代码。示例 9-3 将所有这些函数合并为一个单一的函数(dtobuf),并为其他三个函数提供单独的代码入口。

// Listing9-3.S 

 `Usual header code snipped` 

// dtobuf 
//
// Convert a dword to a string of 16 hexadecimal digits. 
//
// Inputs: 
//  X0-     Pointer to the buffer. Must have at least 
//          17 bytes available. 
//  X1-     Value to convert 
//
// Outputs: 
//  X0-     Points at zero-terminating byte at the end 
//          of the converted string 
//
// Note:    This function does not preserve any registers. 
//          It is the caller's responsibility to preserve 
//          registers. 
//
//          Registers modified: X0, X2 

            proc    dtobuf 

#define AtoF   ('A'-'9'-1) 

            // Process the HO nibble: 

          ❶ lsr     x2, x1, #60 
            orr     w2, w2, #'0'    // Convert to 0x30 to 0x3F. 
            cmp     w2, #'9'        // See if 0x3A to 0x3F. 
            bls     dec15           // Skip if 0 to 9\. 
            add     w2, w2, #AtoF   // If it was A to F 
 dec15: 
            strb    w2, [x0], #1    // Store byte to memory. 

            // Process nibble 14: 

            lsr     x2, x1, #56     // See comments for HO nibble. 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec14 
            add     w2, w2, #AtoF 
dec14:      strb    w2, [x0], #1 

            // Process nibble 13: 

            lsr     x2, x1, #52 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec13 
            add     w2, w2, #AtoF 
dec13:      strb    w2, [x0], #1 

            // Process nibble 12: 

            lsr     x2, x1, #48 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec12 
            add     w2, w2, #AtoF 
dec12:      strb    w2, [x0], #1 

            // Process nibble 11: 

            lsr     x2, x1, #44 
            and     x2, x2, 0xf 
 orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec11 
            add     w2, w2, #AtoF 
dec11:      strb    w2, [x0], #1 

            // Process nibble 10: 

            lsr     x2, x1, #40 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec10 
            add     w2, w2, #AtoF 
dec10:      strb    w2, [x0], #1 

            // Process nibble 9: 

            lsr     x2, x1, #36 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec9 
            add     w2, w2, #AtoF 
dec9:       strb    w2, [x0], #1 

            // Process nibble 8: 

            lsr     x2, x1, #32 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec8 
            add     w2, w2, #AtoF 
dec8:       strb    w2, [x0], #1 

// Entry point for wtobuf 
//
// wtobuf 
//
// Convert a word to a string of 8 hexadecimal digits. 
//
// Inputs: 
//  X0-     Pointer to the buffer. Must have at least 
//          9 bytes available. 
//  X1-     Value to convert 
//
// Outputs: 
//  X0-     Points at zero-terminating byte at the end 
//          of the converted string 
//
// Note:    This function does not preserve any registers. 
//          It is the caller's responsibility to preserve 
//          registers. 
//
//          Registers modified: X0, X2 

❷ wtobuf: 
            // Process nibble 7: 

            lsr     x2, x1, #28 // See comments for nibble 15\. 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec7 
            add     w2, w2, #AtoF 
dec7:       strb    w2, [x0], #1 

            // Process nibble 6: 

            lsr     x2, x1, #24 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec6 
            add     w2, w2, #AtoF 
dec6:       strb    w2, [x0], #1 

            // Process nibble 5: 

            lsr     x2, x1, #20 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec5 
            add     w2, w2, #AtoF 
dec5:       strb    w2, [x0], #1 

            // Process nibble 4: 

            lsr     x2, x1, #16 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec4 
            add     w2, w2, #AtoF 
dec4:       strb    w2, [x0], #1 

// Entry point for htobuf: 
//
// htobuf 
//
// Convert a half word to a string of 4 hexadecimal digits. 
//
// Inputs: 
//  X0-     Pointer to the buffer. Must have at least 
//          5 bytes available. 
//  X1-     Value to convert 
//
// Outputs: 
//  X0-     Points at zero-terminating byte at the end 
//          of the converted string 
//
// Note:    This function does not preserve any registers. 
//          It is the caller's responsibility to preserve 
//          registers. 
//
//          Registers modified: X0, X2 

❸ htobuf: 
            // Process nibble 3: 

            lsr     x2, x1, #12 // See comments for nibble 15\. 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec3 
            add     w2, w2, #AtoF 
dec3:       strb    w2, [x0], #1 

            // Process nibble 2: 

            lsr     x2, x1, #8 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec2 
            add     w2, w2, #AtoF 
dec2:       strb    w2, [x0], #1 

// Entry point for btobuf: 
//
// btobuf 
//
// Convert a byte to a string of two hexadecimal digits. 
//
// Inputs: 
//  X0-     Pointer to the buffer. Must have at least 
//          3 bytes available. 
//  X1-     Value to convert 
//
// Outputs: 
//  X0-     Points at zero-terminating byte at the end 
//          of the converted string 
//
// Note:    This function does not preserve any registers. 
//          It is the caller's responsibility to preserve 
//          registers. 
//
//          Registers modified: X0, X2 

 // Process nibble 1: 

❹ btobuf: 
            lsr     x2, x1, #4      // See comments for nibble 15\. 
            and     x2, x2, 0xf 
            orr     w2, w2, #'0' 
            cmp     w2, #'9' 
            bls     dec1 
            add     w2, w2, #AtoF 
dec1:       strb    w2, [x0], #1 

            // Process LO nibble: 

            and     x2, x1, 0xf 
            orr     x2, x2, #'0' 
            cmp     w2, #'9' 
            bls     dec0 
            add     w2, w2, #AtoF 
dec0:       strb    w2, [x0], #1 

            strb    wzr, [x0]       // Zero-terminate. 
            ret 
            endp    dtobuf 

dtobuf 函数首先处理 dword ❶ 的 HO nibble(nibble 15)。为了提高性能,这段代码使用了展开的循环,逐个处理每个 nibble。每个 nibble 使用标准算法将二进制值转换为十六进制字符。

在这段代码处理完 HO 八个十六进制数字后,你会注意到 wtobuf 函数 ❷ 的入口点。调用 wtobuf 的代码会将控制转移到 dtobuf 函数的中间部分(字面意义上)。之所以能这样工作,是因为 dtobuf 不会将任何内容压入堆栈,也不会以其他方式改变环境,因此 wtobuf 进入时不需要特殊处理。类似地,htobuf ❸ 和 btobuf ❹ 的入口点分别位于 nibble 3 和 nibble 1。通过将这些函数合并到一个代码段中,你节省了 wtobuf、htobuf 和 btobuf 函数所需的所有代码。

我做了几次失败的优化尝试。首先,我尝试将 8 个字节保存在一个寄存器中,并按双字(dword)而不是按字节逐个写入内存。这在我的 Mac mini M1 上运行得更慢。我还尝试通过使用 csel 指令消除代码中的分支,结果代码反而变得更慢。甚至我还尝试使用 ubfx 指令(请参见第十二章),但它的执行速度仍然比带分支的代码更慢。我在 Mac mini M1 和 Raspberry Pi 400 上对这些版本进行了计时。尽管这两台机器的计时差异很大,但三种算法的相对性能保持不变(带分支的版本始终更快)。有时候,使用不同的算法技巧反而会适得其反。这就是为什么你应该始终测试你的代码性能(最好是在多种架构上进行测试)的原因。

在处理完 *tobuf 函数后,编写 toStr 函数相对容易。toStr 函数仅调用 *tobuf 函数,并保留 *tobuf 函数修改的寄存器。清单 9-4 提供了这些函数的代码(请注意,在线文件中的 Listing9-4.S 还包括了 dtobuf 函数的代码;为了避免冗余,我已经将该代码从清单中移除)。

// Listing9-4.S 
//
// btoStr, htoStr, wtoStr, and dtoStr functions 
// Also includes btobuf, htobuf, wtobuf, and 
// dtobuf functions 

            #include    "aoaa.inc"

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-4"

            .data 

// Buffer space used by main program 

buffer:     .space      256,0 

            .code 
            .extern     printf 

// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

❶ `Insert the code for dtobuf here. See Listing 9-3.` 

// btoStr-
//
// Inputs: 
//
//  X0- Pointer to buffer that will hold the result 
//      (must allocate at least 3 bytes for buffer) 
//  X1- Value to print (in LO byte) 
//
// Outputs: 
//
//  Buffer pointed at by X0 receives the two-character 
//  conversion of the value in X1 to a hexadecimal string. 
//
//  Preserves all registers. 

          ❷ proc    btoStr 

            str     x2, [sp, #-16]! 
            stp     x0, lr, [sp, #-16]! 

 bl      btobuf 

            // Restore registers and return: 

            ldp     x0, lr, [sp], #16 
            ldr     x2, [sp], #16 
            ret 
            endp    btoStr 

// htoStr 
//
// Inputs: 
//
//  X0- Pointer to buffer that will hold the result 
//      (must allocate at least 5 bytes for buffer) 
//  X1- Value to print (in LO hword) 
//
// Outputs: 
//
//  Buffer pointed at by X0 receives the four-character 
//  conversion of the hword value in X1 to a hexadecimal string. 
//
//  Preserves all registers 

          ❸ proc    htoStr 

            str     x2, [sp, #-16]! 
            stp     x0, lr, [sp, #-16]! 

            bl      htobuf 

            // Restore registers and return: 

            ldp     x0, lr, [sp], #16 
            ldr     x2, [sp], #16 
            ret 
            endp    htoStr 

// wtoStr 
//
// Inputs: 
//
//  X0- Pointer to buffer that will hold the result 
//      (must allocate at least 9 bytes for buffer) 
//  X1- Value to print (in LO word) 
//
// Outputs: 
//
//  Buffer pointed at by X0 receives the eight-character 
//  conversion of the word value in X1 to a hexadecimal string. 
//
//  Preserves all registers 

          ❹ proc    wtoStr 

 str     x2, [sp, #-16]! 
            stp     x0, lr, [sp, #-16]! 

            bl      wtobuf 

            // Restore registers and return: 

            ldp     x0, lr, [sp], #16 
            ldr     x2, [sp], #16 
            ret 
            endp    wtoStr 

// dtoStr 
//
// Inputs: 
//
//  X0- Pointer to buffer that will hold the result 
//      (must allocate at least 17 bytes for buffer) 
//  X1- Value to print 
//
// Outputs: 
//
//  Buffer pointed at by X0 receives the 16-character 
//  conversion of the dword value in X1 to a hexadecimal string. 
//
//  Preserves all registers 

          ❺ proc    dtoStr 

            str     x2, [sp, #-16]! 
            stp     x0, lr, [sp, #-16]! 

            bl      dtobuf 

            // Restore registers and return: 

            ldp     x0, lr, [sp], #16 
            ldr     x2, [sp], #16 
            ret 
            endp    dtoStr 

// Utility functions to print bytes, hwords, words, and dwords: 

pbStr:      wastr   "Byte=%s\n"

            proc    pByte 

            locals  pb 
            qword   pb.saveX0X1 
            byte    pb.buffer, 32 
            byte    pb.stkSpace, 64 
            endl    pb 

            enter   pb.size 
            stp     x0, x1, [fp, #pb.saveX0X1] 

 mov     x1, x0 
            add     x0, fp, #pb.buffer  // lea x0, stkSpace 
            bl      btoStr 

            lea     x0, pbStr 
            add     x1, fp, #pb.buffer 
            mstr    x1, [sp] 
            bl      printf 

            ldp     x0, x1, [fp, #pb.saveX0X1] 
            leave 
            endp    pByte 

phStr:      wastr   "Hword=%s\n"

            proc    pHword 

            locals  ph 
            qword   ph.saveX0X1 
            byte    ph.buffer, 32 
            byte    ph.stkSpace, 64 
            endl    ph 

            enter   ph.size 
            stp     x0, x1, [fp, #ph.saveX0X1] 

            mov     x1, x0 
            add     x0, fp, #ph.buffer  // lea x0, stkSpace 
            bl      htoStr 

            lea     x0, phStr 
            add     x1, fp, #ph.buffer 
            mstr    x1, [sp] 
            bl      printf 

            ldp     x0, x1, [fp, #ph.saveX0X1] 
            leave 
            endp    pHword 

pwStr:      wastr   "Word=%s\n"

            proc    pWord 

            locals  pw 
            qword   pw.saveX0X1 
            byte    pw.buffer, 32 
            byte    pw.stkSpace, 64 
            endl    pw 

            enter   pw.size 
            stp     x0, x1, [fp, #pw.saveX0X1] 

            mov     x1, x0 
            add     x0, fp, #pw.buffer  // lea x0, stkSpace 
            bl      wtoStr 

 lea     x0, pwStr 
            add     x1, fp, #pw.buffer 
            mstr    x1, [sp] 
            bl      printf 

            ldp     x0, x1, [fp, #pw.saveX0X1] 
            leave 
            endp    pWord 

pdStr:      wastr   "Dword=%s\n"

            proc    pDword 

            locals  pd 
            qword   pd.saveX0X1 
            byte    pd.buffer, 32 
            byte    pd.stkSpace, 64 
            endl    pd 

            enter   pd.size 
            stp     x0, x1, [fp, #pd.saveX0X1] 

            mov     x1, x0 
            add     x0, fp, #pd.buffer  // lea x0, stkSpace 
            bl      dtoStr 

            lea     x0, pdStr 
            add     x1, fp, #pd.buffer 
            mstr    x1, [sp] 
            bl      printf 

            ldp     x0, x1, [fp, #pd.saveX0X1] 
            leave 
            endp    pDword 

// Here is the asmMain function: 

            proc    asmMain, public 

            // Local storage: 

            locals  am 
            byte    stackspace, 64 
            endl    am 

            enter   am.size             // Create activation record. 

            ldr     x0, =0x0123456789abcdef 
            bl      pByte 
            bl      pHword 
            bl      pWord 
            bl      pDword 

            leave 
 ret 

            endp    asmMain 

如上所述,我已将 dtobuf 函数从此清单中移除;请插入该代码 ❶。btoStr 函数 ❷ 将 X0、X2 和 LR 寄存器保存在堆栈中(这些寄存器会被 *tobuf 函数调用修改),调用 btobuf 函数将两个十六进制数字写入 X0 所指向的缓冲区,然后恢复寄存器并返回。对于 htoStr ❸、wtoStr ❹ 和 dtoStr ❺,代码基本相同,唯一的区别是它们调用的转换函数不同。

以下是清单 9-4 中程序的构建命令和示例输出:

$ ./build Listing9-4 
$ ./Listing9-4 
Calling Listing9-4: 
Byte=EF 
Hword=CDEF 
Word=89ABCDEF 
Dword=0123456789ABCDEF 
Listing9-4 terminated 

由于本书中出现的汇编代码调用了 C/C++ 标准库函数进行 I/O 操作,因此这些二进制到十六进制字符串的函数将生成零终止的 C 兼容字符串。如果需要的话,它们足够简单,能够修改以生成其他字符串格式。更多关于字符串函数的内容,请参见第十四章。

9.1.2 扩展精度的十六进制值转换为字符串

扩展精度的十六进制到字符串转换很简单:它只是上一节中正常的十六进制转换例程的扩展。例如,清单 9-5 是一个 128 位十六进制转换函数 qtoStr,它期望 X2:X1 中的指针指向一个 128 位值,而 X0 中的指针指向一个缓冲区。Listing9-5.S 基本上是基于 Listing9-4.S;为了避免冗余,这里只包含了 qtoStr 函数。

// Listing9-5.S 
//
// qtoStr 
//
// Inputs: 
//
//  X0-     Pointer to buffer that will hold the result 
//          (must allocate at least 33 bytes for buffer) 
//  X2:X1-  Value to print 
//
// Outputs: 
//
//  Buffer pointed at by X0 receives the 32-character 
//  conversion of the dword value in X2:X1 to a hexadecimal string. 
//
//  Preserves all registers 

            proc    qtoStr 

            str     x2, [sp, #-16]! 
            stp     x0, lr, [sp, #-16]! 
            str     x1, [sp, #-16]!     // Save for later. 

            mov     x1, x2              // Convert HO dword first. 
            bl      dtobuf 
            ldr     x1, [sp], #16       // Restore X1 value. 
            bl      dtobuf 

            // Restore registers and return: 

            ldp     x0, lr, [sp], #16 
            ld4     x2, [sp], #16 
            ret 
            endp    qtoStr 

清单 9-5 中的函数调用了 dtobuf 两次,通过首先转换 HO 双字(dword),然后转换 LO 双字(dword),并连接它们的结果,将 128 位的 qword 值转换为字符串。要将此转换扩展到任意数量的字节,只需将 HO 字节转换为大对象的 LO 字节即可。

9.1.3 无符号十进制值转换为字符串

十进制输出比十六进制输出稍微复杂一些,因为与十六进制值不同,二进制数的高位(HO)位会影响十进制表示中的低位(LO)数字。因此,必须通过一次提取一个十进制数字的方式来创建二进制数的十进制表示。

无符号十进制输出的最常见解决方案是不断将值除以 10,直到结果变为 0。第一次除法后的余数是 0 到 9 之间的值,对应十进制数字的低位。不断进行除法(以及其相应的余数)会依次提取数字中的各个数字。

该问题的迭代解决方案通常会为一个足够大的字符字符串分配存储空间,以容纳整个数字。代码接着会在循环中提取十进制数字,并逐个将它们放入该字符串中。在转换过程结束时,例程会按反向顺序打印字符串中的字符(记住,除法算法首先提取低位数字,最后提取高位数字,这与打印时需要的顺序相反)。

本节采用了递归解决方案,因为它更为优雅。该解决方案通过将值除以 10 并将余数保存在局部变量中开始。如果商不为 0,例程将递归调用自身以首先输出所有前导数字。从递归调用返回(递归调用输出所有前导数字)后,递归算法将输出与余数相关的数字以完成操作。例如,下面是打印十进制值 789 时操作的执行过程:

1.  将 789 除以 10,商为 78,余数为 9。

2.  将余数(9)保存在局部变量中,并使用商递归调用该例程。

3.  递归入口 1:将 78 除以 10,商为 7,余数为 8。

4.  将余数(8)保存在局部变量中,并使用商递归调用该例程。

5.  递归入口 2:将 7 除以 10,商为 0,余数为 7。

6.  将余数(7)保存在局部变量中。由于商为 0,因此不再递归调用该例程。

7.  输出保存在局部变量(7)中的余数值。返回到调用者(递归入口 1)。

8.  返回到递归入口 1:输出在递归入口 1(8)中保存在局部变量中的余数值。返回到调用者(最初调用该过程的地方)。

9.  最初的调用:输出在最初调用(9)中保存在局部变量中的余数值。返回到输出例程的最初调用者。

列表 9-6 提供了 64 位无符号整数的递归算法实现。

// Listing9-6.S 
//
// u64toBuf function 

            #include    "aoaa.inc"

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-6"
fmtStr1:    .asciz      "Value(%llu) = string(%s)\n"

            .align      3 
qwordVal:   .dword      0x1234567890abcdef 
            .dword      0xfedcba0987654321 

            .data 
buffer:     .space      256,0 

            .code 
            .extern     printf 

// Return program title to C++ program: 

 proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

// u64ToStr 
//
//  Converts a 64-bit unsigned integer to a string 
//
//  Inputs: 
//      X0-     Pointer to buffer to receive string 
//      X1-     Unsigned 64-bit integer to convert 
//
//  Outputs: 
//      Buffer- Receives the zero-terminated string 
//
//  Buffer must have at least 21 bytes allocated for it. 
//  This function preserves all registers. 

          ❶ proc    u64ToStr 
            stp     x0, x1, [sp, #-16]! 
            stp     x2, x3, [sp, #-16]! 
            str     lr, [sp, #-16]! 

            bl      u64ToBuf 

            ldr     lr, [sp], #16 
            ldp     x2, x3, [sp], #16 
            ldp     x0, x1, [sp], #16 
            ret 
            endp    u64ToStr 

// u64ToBuf 
//
//  Converts a 64-bit unsigned integer to a string 
//
//  Inputs: 
//      X0-     Pointer to buffer to receive string 
//      X1-     Unsigned 64-bit integer to convert 
//
//  Outputs: 
//      X0-     Points at zero-terminating byte 
//      Buffer- Receives the zero-terminated string 
//
//  Buffer must have at least 21 bytes allocated for it. 
//
//  Caller must preserve X0, X1, X2, and X3! 

          ❷ proc    u64ToBuf 
            cmp     x1, xzr         // See if X1 is 0\. 
            bne     u64ToBufRec 

            // Special case for zero, just write 
            // "0" to the buffer. Leave X0 pointing 
 // at the zero-terminating byte. 

            mov     w1, #'0' 
            strh    w1, [x0], #1    // Also emits zero byte 
            ret 
            endp    u64ToBuf 

// u64ToBufRec is the recursive version that handles 
// nonzero values: 

          ❸ proc    u64ToBufRec 
            stp     x2, lr, [sp, #-16]! // Preserve remainder. 

            // Divide X1 by 10 and save quotient and remainder: 

          ❹ mov     x2, #10 
            udiv    x3, x1, x2      // X3 = quotient 
            msub    x2, x3, x2, x1  // X2 = remainder 

            // Make recursive call if quotient is not 0: 

            cmp     x3, xzr 
            beq     allDone 

          ❺ mov     x1, x3              // Set up for call. 
            bl      u64ToBufRec 

            // When this function has processed all the 
            // digits, write them to the buffer. Also 
            // write a zero-terminating byte, in case 
            // this is the last digit to output. 

❻ allDone:    orr     w2, w2, #'0'    // Convert to char. 
            strh    w2, [x0], #1    // Bump pointer after store. 
          ❼ ldp     x2, lr, [sp], #16 
            ret 
            endp    u64ToBufRec 

// Here is the "asmMain" function. 

            proc    asmMain, public 

            enter   64              // Reserve space on stack. 

// Test u64ToBuf: 

            mov     x1, 0xFFFF 
            movk    x1, 0xFFFF, lsl #16 
            movk    x1, 0xFFFF, lsl #32 
            movk    x1, 0xFFFF, lsl #48 
            lea     x0, buffer 
            bl      u64ToStr 

            lea     x2, buffer 
 mstr    x2, [sp, #8] 
            mov     x1, 0xFFFF 
            movk    x1, 0xFFFF, lsl #16 
            movk    x1, 0xFFFF, lsl #32 
            movk    x1, 0xFFFF, lsl #48 
            mstr    x1, [sp] 
            lea     x0, fmtStr1 
            bl      printf 

            leave 
            ret 
            endp    asmMain 

u64toStr 函数❶是一个外观函数,在调用 u64ToBuf 过程时保存寄存器。u64ToBuf 函数❷处理 X1 包含 0 的特殊情况(当结果为 0 时,递归代码终止)。如果 X1 在进入时为 0,代码会立即将字符'0'写入输出缓冲区,递增 X0 并返回。如果 X1 非零,则控制权转交给递归的 u64toBufRec 函数❸来处理该值。为了性能考虑,u64ToBufRec 只保留 X2(在递归调用中包含余数值)和 LR 寄存器。

递归函数计算商和余数❹。商保存在 X3 中,余数保存在 X2 中。如果商不为零,说明还有更多的高位数字需要处理:将商复制到 X1 并进行递归调用 u64toBufRec❺。从递归调用返回❻(或者如果跳过了递归调用),所有的高位数字已经被输出到缓冲区,因此将当前数字转换为字符并添加到缓冲区的末尾。注意,后增量寻址模式会自动将 X0 递增,以指向由 strh 指令发出的以零结束的字节。代码恢复 X2 中的值❼,以防这是一次递归调用。

以下是第 9-6 列表的构建命令和示例输出:

$ ./build Listing9-6 
$ ./Listing9-6 
Calling Listing9-6: 
Value(18446744073709551615) = string(18446744073709551615) 
Listing9-6 terminated 

与十六进制输出不同,这里不需要提供字节大小、半字大小或字大小的数字到十进制字符串转换函数。只需将较小的值零扩展到 64 位即可。与十六进制转换不同,u64toStr 函数不会输出前导零,因此对所有大小的变量(64 位及更小)输出都是相同的。

这段代码有几个优化的机会。由于十进制到字符串的转换很常见(大多数程序输出都使用这个函数),而且该算法的速度不如十六进制转换快,因此优化这段代码可能是值得的。

去除递归并实现 u64toStr 的迭代版本其实很简单。这可以消除在多次递归调用中保存寄存器和返回地址的需求(通常每个数字转换会有一次递归调用),以及在每次调用时构建激活记录的需要。第 9-7 列表进一步改进了这一点,将循环展开(最多 20 次迭代,每次迭代处理一个可能的数字)。

// Listing9-7.S 
//
// u64toStr function (nonrecursive, straight-line 
// code version) 

            #include    "aoaa.inc"

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-7"
fmtStr1:    .asciz      "low=%s, " 
fmtStr2:    .asciz      "hi=%s\n"

loData:     .dword      0, 1, 10, 100, 1000, 10000, 100000 
            .dword      1000000, 10000000, 100000000 
            .dword      1000000000, 10000000000, 100000000000 
            .dword      1000000000000, 10000000000000 
            .dword      100000000000000, 1000000000000000 
            .dword      10000000000000000, 100000000000000000 
            .dword      1000000000000000000, 10000000000000000000 
            .equ        dataCnt, .-loData 

hiData:     .dword      9, 9, 99, 999, 9999, 99999, 999999 
            .dword      9999999, 99999999, 999999999 
            .dword      9999999999, 99999999999, 999999999999 
            .dword      9999999999999, 99999999999999 
            .dword      999999999999999, 9999999999999999 
            .dword      99999999999999999, 999999999999999999 
            .dword      9999999999999999999 
            .dword      -1 

            .data 
buffer:     .space      256, 0 

            .code 
            .extern     printf 

// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

// u64ToBuf 
//
//  Converts a 64-bit unsigned integer to a string 
//
//  Inputs: 
//      X0-     Pointer to buffer to receive string 
//      X1-     Unsigned 64-bit integer to convert 
//
//  Outputs: 
//      Buffer- Receives the zero-terminated string 
//      X0-     Points at zero-terminating byte in string 
//
//  Buffer must have at least 21 bytes allocated for it. 
//  Note: Caller is responsible for preserving X0-X7! 

          ❶ proc    u64ToBuf 

          ❷ mov     x4, #10 
            mov     x5, xzr 
            mov     x6, xzr 
            mov     x7, xzr 

            // Handle the LO digit here: 

          ❸ udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x2, #0 
            beq     allDone1 

            // Handle the 10's digit here: 

          ❹ udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x1, #0 
            beq     allDone2 

            // Handle the 100's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x2, #0 
            beq     allDone3 

            // Handle the 1000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x1, #0 
            beq     allDone4 

            // Handle the 10,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
 orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x2, #0 
            beq     allDone5 

            // Handle the 100,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 
            cmp     x1, #0 
            beq     allDone6 

            // Handle the 1,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x6, x3, #'0' 
            cmp     x2, #0 
            beq     allDone7 

            // Handle the 10,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x1, #0 
            beq     allDone8 

            // Handle the 100,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x2, #0 
            beq     allDone9 

            // Handle the 1,000,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x1, #0 
            beq     allDone10 

            // Handle the 10,000,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
 orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x2, #0 
            beq     allDone11 

            // Handle the 100,000,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x1, #0 
            beq     allDone12 

            // Handle the 1,000,000,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x2, #0 
            beq     allDone13 

            // Handle the 10,000,000,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x6, x3, x6, lsl #8 
            cmp     x1, #0 
            beq     allDone14 

            // Handle the 100,000,000,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x7, x3, #'0' 
            orr     x6, x3, x6, lsl #8
            cmp     x2, #0 
            beq     allDone15 

            // Handle the 1,000,000,000,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x7, x3, x7, lsl #8 
            cmp     x1, #0 
            beq     allDone16 

            // Handle the 10,000,000,000,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
 orr     x3, x3, #'0' 
            orr     x7, x3, x7, lsl #8     
            cmp     x2, #0 
            beq     allDone17 

            // Handle the 100,000,000,000,000,000's digit here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x7, x3, x7, lsl #8 
            cmp     x1, #0 
            beq     allDone18 

            // Handle the 1,000,000,000,000,000,000's digit here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x7, x3, x7, lsl #8 
            cmp     x2, #0 
            beq     allDone19 

          ❺ udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x7, x3, x7, lsl #8 

allDone20:  str     x7, [x0], #6 
            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 

            // When this function has processed all the 
            // digits, write them to the buffer. Also 
            // write a zero-terminating byte, in case 
            // this is the last digit to output. 

❻ allDone1: strh    w5, [x0], #1 
            ret 

  allDone2: strh    w5, [x0], #2 
            strb    wzr, [x0] 
            ret 

  allDone3: str     w5, [x0], #3 
            ret 

  allDone4: str     w5, [x0], #4 
            strb    wzr, [x0] 
            ret 

  allDone5: str     x5, [x0], #4 
            lsr     x5, x5, #32 
 strh    w5, [x0], #1 
            ret 

  allDone6: str     w5, [x0], #4 
            lsr     x5, x5, #32 
            strh    w5, [x0], #2 
            strb    wzr, [x0] 
            ret 

❼ allDone7: strb    w6, [x0], #1 
            str     x5, [x0], #7 
            ret 

  allDone8: strh    w6, [x0], #2 
            str     x5, [x0], #7    // Writes an extra garbage byte 
            ret 

  allDone9: str     w6, [x0], #3 
            str     x5, [x0], #7 
            ret 

  allDone10: 
            str     w6, [x0], #4 
            str     x5, [x0], #7 
            ret 

  allDone11: 
            str     x6, [x0], #5 
            str     x5, [x0], #7 
            ret 

  allDone12: 
            str     x6, [x0], #6 
            str     x5, [x0], #7 
            ret 

  allDone13: 
            str     x6, [x0], #7 
            str     x5, [x0], #7 
            ret 

  allDone14: 

            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 

❽ allDone15: 
            strb    w7, [x0], #1 
            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 

  allDone16: 
            strh    w7, [x0], #2 
            str     x6, [x0], #8 
 str     x5, [x0], #7 
            ret 

  allDone17: 
            str     w7, [x0], #3 
            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 

  allDone18: 
            str     w7, [x0], #4 
            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 

  allDone19: 
            str     x7, [x0], #5 
            str     x6, [x0], #8 
            str     x5, [x0], #7 
            ret 
            endp    u64ToBuf 

// u64ToStr 
//
//  Version of u64ToBuf that preserves the registers 

          ❾ proc    u64ToStr 
            stp     x0, x1, [sp, #-16]! // Preserve registers. 
            stp     x2, x3, [sp, #-16]! 
            stp     x4, x5, [sp, #-16]! 
            stp     x6, x7, [sp, #-16]! 
            str     lr, [sp, #-16]! 
            bl      u64ToBuf 
            ldr     lr, [sp], #16 
            ldp     x6, x7, [sp], #16   // Restore registers. 
            ldp     x4, x5, [sp], #16 
            ldp     x2, x3, [sp], #16 
            ldp     x0, x1, [sp], #16 
            ret 
            endp    u64ToStr 

// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            qword   am.x20_x21 
            dword   am.x22 
            byte    stk, 64 
            endl    am 

            enter   am.size             // Create act rec. 

 // Preserve nonvolatile registers: 

            stp     x20, x21, [fp, #am.x20_x21] 
            str     x22, [fp, #am.x22] 

            lea     x20, loData 
            lea     x21, hiData 
            mov     x22, xzr 
 loop: 
            lea     x0, buffer 
            ldr     x1, [x20, x22, lsl #3] 
            bl      u64ToStr 

            lea     x0, fmtStr1 
            lea     x1, buffer 
            mstr    x1, [sp] 
            bl      printf 

            lea     x0, buffer 
            ldr     x1, [x21, x22, lsl #3] 
            bl      u64ToStr 

            lea     x0, fmtStr2 
            lea     x1, buffer 
            mstr    x1, [sp] 
            bl      printf 

            add     x22, x22, #1 
            cmp     x22, #(dataCnt / 8) 
            blo     loop 

            ldr     x22, [fp, #am.x22] 
            ldp     x20, x21, [fp, #am.x20_x21] 

            leave 
            endp    asmMain 

u64ToBuf 函数❶是 u64ToStr 的一个变体,它不会保存任何寄存器。它会覆盖 X0 到 X7 寄存器,调用者需要负责保存任何需要保留的寄存器。

该函数将常数 10 初始化到 X4 ❷,因为每次数字转换都将除以并乘以该常数,而该常数必须存储在寄存器中。将 X4 保留为常数可以避免代码每次都重新加载该常数。此代码将 X5、X6 和 X7 清零,这些寄存器将存储转换后的字符串字符;这也初始化了零终止字节(根据输出数字的数量,可能位于这些寄存器的不同位置)。

该函数通过使用与清单 9-6 ❸ 中程序相同的基本“除法和余数”算法,将二进制数转换为一串数字字符。函数将值除以 10,余数是一个范围在 0 到 9 之间的值,函数将其转换为相应的 ASCII 字符。代码将转换后的数字移到 X5、X6 或 X7 寄存器的最终输出位置。1 到 6 位数字,即高位数字,最终存储在 X5 中;7 到 14 位数字存储在 X6 中;15 到 20 位数字存储在 X7 中。零字节填充所有未使用的数字位置。例如,如果数字只有三位,X6 和 X7 将包含 0,而 X5 中的 24 到 63 位将全部为 0。

每个可能的输出数字转换使用一组单独的除法/余数指令(因此称为 展开/直线代码) ❹。每个数字的转换序列大致相同,尽管有两个变体在 X1 和 X2 之间交替,因为除法的商成为下一步要除的值。每当商变为 0,转换完成,控制转移到不同的位置,将转换后的数字写入缓冲区。函数中只会采取单个分支,因为这些分支会继续执行下一条指令序列,直到转换完成。此外,这些数字转换序列可能会根据数字的最终位置将转换后的数字放入不同的输出寄存器。

如果代码执行到第 20 位数字,没有对 0 结果进行测试;此时商将始终为 0,因此函数简单地将数字存储到缓冲区中并返回 ❺。

如果数字有六位或更少,函数将 X5 中的字符写入缓冲区 ❻。X5 将始终包含数字的低位数字。通过将最多六个字符放入 X5,X5 的高位 2 字节将始终为 0(并为更大的字符串提供零终止字节)。对于少于六位的数字,代码必须显式地将零终止字节写入缓冲区。对于 7 到 14 位的数字,函数将寄存器 X6 和 X5(按此顺序)写入缓冲区 ❼。X5 提供零终止字节,因此代码不需要显式地写入任何 0 字节。对于 15 位或更多位的数字,代码将寄存器 X7、X6 和 X5 中的数据写出(X5 提供零终止字节) ❽。

实际的 u64ToStr 函数❾是一个简单的外观函数,它在调用 u64ToBuf 时保留所有寄存器的值。通过将 u64ToStr 分解为这两个函数,如果你希望 X0 指向字符串的末尾,可以直接调用 u64ToBuf(尽管如果需要,你必须保留 X1 到 X7)。此外,将寄存器保存代码放入 u64ToStr 中,允许 u64ToBuf 代码避免在所有 ret 指令之前恢复寄存器(或者避免再次跳转到处理恢复寄存器的代码)。

以下是来自清单 9-7 的构建命令和示例输出:

$ ./build Listing9-7 
$ time ./Listing9-7 
Calling Listing9-7: 
low=0, hi=9 
low=1, hi=9 
low=10, hi=99 
low=100, hi=999 
low=1000, hi=9999 
low=1000, hi=9999 
low=100000, hi=999999 
low=1000000, hi=9999999 
low=10000000, hi=99999999 
low=100000000, hi=999999999 
low=1000000000, hi=9999999999 
low=10000000000, hi=99999999999 
low=100000000000, hi=999999999999 
low=1000000000000, hi=9999999999999 
low=10000000000000, hi=99999999999999 
low=100000000000000, hi=999999999999999 
low=1000000000000000, hi=9999999999999999 
low=10000000000000000, hi=99999999999999999 
low=100000000000000000, hi=999999999999999999 
low=1000000000000000000, hi=9999999999999999999 
low=10000000000000000000, hi=18446744073709551615 
Listing9-7 terminated 

我修改了 u64toStr 的两个版本,以便对它们的执行时间进行计时。在递归版本中,我在我的 Mac mini 上获得了以下的计时结果:

Listing9-7a  404.58s user 0.42s system 99% cpu 6:46.25 total 

对于直线代码,运行时间如下:

Listing9-7a  173.60s user 0.15s system 99% cpu 2:53.78 total 

后者的代码运行速度比递归版本快了约 2.3 倍,这是一个很大的进步。

我还创建了一个版本的 u64ToStr,它首先计算输出数字的数量(使用二分查找),然后跳转到适当的代码以精确地转换该数量的数字。可惜的是,代码比清单 9-7 运行得稍慢。我还尝试了一个变体,它首先输出高位数字(通过 1e+19 进行除法,接下来每次除以 10)。它比数字计数版本略快,比清单 9-7 略慢。我已将两个实验的源代码包含在在线文件中,供你参考。

9.1.4 将带符号整数值转换为字符串

要将带符号的整数值转换为字符串,首先检查数字是否为负。如果是,则输出一个短横线(-)字符并取反该值,然后调用 u64toStr 函数完成转换。清单 9-8 显示了相关代码。

// Listing9-8.S 

`Code taken from Listing 9-7 goes here.` 

// i64ToStr 
//
//  Converts a signed 64-bit integer to a string 
//  If the number is negative, this function will 
//  print a '-' character followed by the conversion 
//  of the absolute value of the number. 
//
// Inputs: 
//
//      X0- Pointer to buffer to hold the result. 
//          Buffer should be capable of receiving 
//          as many as 22 bytes (including zero-
//          terminating byte). 
//      X1- Signed 64-bit integer to convert 
//
// Outputs: 
//
//      Buffer- Contains the converted string 

            proc    i64ToStr 

            locals  i64 
            dword   i64.x0 
            byte    i64.stk, 32 
            endl    i64 

            enter   i64.size 

            // Need to preserve X1 in 
            // case this code negates it. 

            str     x1, [fp, #i64.x0] 

            cmp     x1, #0 
            bpl     isPositive 

            mov     w1, #'-'    // Emit '-' 
            strb    w1, [x0], #1 

            // Negate X0 and convert 
            // unsigned value to integer: 

            ldr     x1, [fp, #i64.x0] 
            neg     x1, x1 

isPositive: bl      u64ToStr 
            ldr     x1, [fp, #i64.x0] 
            leave 
            endp    i64ToStr 

`Code taken from Listing 9-7 goes here.` 

清单 9-8 仅显示了 i64ToStr 函数(程序的其余部分来自清单 9-7)。完整的源代码可以在线获取。

9.1.5 扩展精度无符号整数转换为字符串

整个字符串转换算法中唯一需要扩展精度算术的操作是除以 10。清单 9-9 实现了一个利用该技术的 128 位十进制输出例程。我修改了第八章中的 div128 算法,使其进行显式的除以 10 操作(稍微加快了 div128 的速度),并修改了来自清单 9-6 的递归转换例程以执行该转换。

// Listing9-9.S 
//
// u128toStr function 

            #include    "aoaa.inc"

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-9"
fmtStr1:    .asciz      "Value = %s\n"

qdata:      .qword      1 
            .qword      21 
            .qword      302 
            .qword      4003 
            .qword      50004 
            .qword      600005 
            .qword      7000006 
            .qword      80000007 
            .qword      900000008 
            .qword      1000000009 
            .qword      11000000010 
            .qword      120000000011 
            .qword      1300000000012 
            .qword      14000000000013 
            .qword      150000000000014 
            .qword      1600000000000015 
            .qword      17000000000000016 
            .qword      180000000000000017 
            .qword      1900000000000000018 
            .qword      20000000000000000019 
            .qword      210000000000000000020 
            .qword      2200000000000000000021 
            .qword      23000000000000000000022 
            .qword      240000000000000000000023 
            .qword      2500000000000000000000024 
            .qword      26000000000000000000000025 
            .qword      270000000000000000000000026 
            .qword      2800000000000000000000000027 
            .qword      29000000000000000000000000028 
            .qword      300000000000000000000000000029 
            .qword      3100000000000000000000000000030 
            .qword      32000000000000000000000000000031 
            .qword      330000000000000000000000000000032 
            .qword      3400000000000000000000000000000033 
            .qword      35000000000000000000000000000000034 
            .qword      360000000000000000000000000000000035 
            .qword      3700000000000000000000000000000000036 
            .qword      38000000000000000000000000000000000037 
            .qword      300000000000000000000000000000000000038 
            .qword      340282366920938463463374607431768211455 
qcnt        =           (.-qdata)/16 

 .data 
buffer:     .space      256,0 

            .code 
            .extern     printf 

// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

// div10 
//
// This procedure does a general 128-bit / 10 division operation 
// using the following algorithm (assume all variables except 
// Remainder are 128-bit objects; Remainder is 64 bits): 
//
// Quotient := Dividend; 
// Remainder := 0; 
// for i := 1 to NumberBits do 
//
//  Remainder:Quotient := Remainder:Quotient SHL 1; 
//  if Remainder >= 10 then 
//
//     Remainder := Remainder - 10; 
//     Quotient := Quotient + 1; 
//
//  endif 
// endfor 
//
// Data passed: 
//
// 128-bit dividend in X6:X5 
//
// Data returned: 
//
// 128-bit quotient in X6:X5 
// 64-bit remainder in X4 
//
// Modifies X1 

          ❶ proc    div10 

#define remainder  x4 
#define dividendL  x5 
#define dividendH  x6 
#define quotientL  dividendL 
#define quotientH  dividendH 

// Initialize remainder with 0: 

            mov     remainder, #0 

// Copy the dividend to local storage: 

            mov     w1, #128           // Count off bits in W0\. 

// Compute Remainder:Quotient := Remainder:Quotient LSL 1 
//
// Note: adds x, x, x is equivalent to lsl x, x, #1 
//       adcs x, x, x is equivalent to rol x, x, #1 
//                    (if rol existed) 
//
// The following four instructions perform a 256-bit 
// extended-precision shift (left) dividend through 
// remainder. 

repeatLp:   adds    dividendL, dividendL, dividendL 
            adcs    dividendH, dividendH, dividendH 
            adc     remainder, remainder, remainder 

// Do a comparison to see if the remainder 
// is greater than or equal to 10: 

            cmp     remainder, #10 
            blo     notGE 

// Remainder := Remainder - Divisor 

isGE:       sub     remainder, remainder, #10 

// Quotient := Quotient + 1 

            adds    quotientL, quotientL, #1 
            adc     quotientH, quotientH, xzr 

// Repeat for 128 bits: 

notGE:      subs    w1, w1, #1 
            bne     repeatLp 

            ret     // Return to caller. 
            endp    div10 

// u128toStr: 
//
//  Converts a 128-bit unsigned integer to a string 
//
//  Inputs: 
//      X0-     Pointer to buffer to receive string 
//      X1-     Points at the unsigned 128-bit integer to convert 
//
//  Outputs: 
//      Buffer- Receives the zero-terminated string 
//
//  Buffer must have at least 40 bytes allocated for it. 

 ❷ proc    u128toStr 
            stp     x0, x1, [sp, #-16]! 
            stp     x4, x5, [sp, #-16]! 
            stp     x6, lr, [sp, #-16]! 

            ldp     x5, x6, [x1]    // Test value for 0\. 
            orr     x4, x5, x6 
            cmp     x4, xzr         // Z = 1 if X6:X5 is 0\. 
            bne     doRec128 

            // Special case for zero, just write 
            // "0" to the buffer 

            mov     w4, #'0' 
            strb    w4, [x0], #1 
            b.al    allDone2 

doRec128:   bl      u128toStrRec    // X6:X5 contain value. 

            // Restore registers: 

allDone2:   strb    wzr, [x0]       // Zero-terminating byte 
            ldp     x6, lr, [sp], #16 
            ldp     x4, x5, [sp], #16 
            ldp     x0, x1, [sp], #16 
            ret 
            endp    u128toStr 

// u128toStrRec is the recursive version that handles 
// nonzero values. 
//
// Value to convert is passed in X6:X5\. 

          ❸ proc    u128toStrRec 
            stp     x4, lr, [sp, #-16]! 

            // Convert LO digit to a character: 

            bl      div10          // Quotient -> X6:X5, Rem -> W4 

            // Make recursive call if quotient is not 0: 

            orr     lr, x5, x6     // Use LR as a temporary. 
            cmp     lr, #0 
            beq     allDone 

            // New value is quotient (X6:X5) from above: 

            bl      u128toStrRec 

            // When this function has processed all the 
            // digits, write them to the buffer: 

allDone:    orr     w4, w4, #'0'    // Convert to char. 
            strb    w4, [x0], #1    // Bump pointer after store. 

 // Restore state and return: 

            ldp     x4, lr, [sp], #16    // Restore prev char. 
            ret 
            endp    u128toStrRec 

// Here is the asmMain function. 

            proc    asmMain, public 

            locals  am 
            dword   am.x2021 
            byte    stk, 64 
            endl    am 

            enter   am.size              // Reserve space on stack. 

            stp     x20, x21, [fp, #am.x2021] 

            lea     x20, qdata 
            mov     x21, #qcnt 
loop:       mov     x1, x20 
            lea     x0, buffer 
            bl      u128toStr 

            lea     x1, buffer 
            mstr    x1, [sp] 
            lea     x0, fmtStr1 
            bl      printf 

            add     x20, x20, #16       // Next value to convert 
            subs    x21, x21, #1 
            bne     loop 

            ldp     x20, x21, [fp, #am.x2021] 
            leave 
            ret 
            endp    asmMain 

代码包括一个优化版的 128 位除法函数,它将数字除以 10❶。接下来是 u128toStr 的非递归入口点,它将 0 作为特例处理,并对所有其他值调用递归版本❷,然后是 u128toStr 的递归代码❸。由于这些函数几乎与递归的 64 位字符串输出函数相同,请参考清单 9-6 中的代码了解更多细节。

u128toStr 函数的一个问题是,它比其他数字到字符串的函数慢得多。这完全是因为 div10 子程序的性能。由于 128 位除以 10 的算法非常慢,我不会费力改进 u128toStr 转换函数的性能。除非你能想出一个高性能的 div10 子程序(或许可以使用倒数相乘;请参见第 9.6 节“更多信息”,第 603 页),否则优化 u128toStr 可能是浪费时间。幸运的是,这个函数可能不会经常被调用,所以它的性能不会有太大影响。

这是来自清单 9-9 的构建命令和示例输出:

$ ./build Listing9-9 
$ ./Listing9-9 
Calling Listing9-9: 
Value = 1 
Value = 21 
Value = 302 
Value = 4003 
Value = 50004 
Value = 600005 
Value = 7000006 
Value = 80000007 
Value = 900000008 
Value = 1000000009 
Value = 11000000010 
Value = 120000000011 
Value = 1300000000012 
Value = 14000000000013 
Value = 150000000000014 
Value = 1600000000000015 
Value = 17000000000000016 
Value = 180000000000000017 
Value = 1900000000000000018 
Value = 20000000000000000019 
Value = 210000000000000000020 
Value = 2200000000000000000021 
Value = 23000000000000000000022 
Value = 240000000000000000000023 
Value = 2500000000000000000000024 
Value = 26000000000000000000000025 
Value = 270000000000000000000000026 
Value = 2800000000000000000000000027 
Value = 29000000000000000000000000028 
Value = 300000000000000000000000000029 
Value = 3100000000000000000000000000030 
Value = 32000000000000000000000000000031 
Value = 330000000000000000000000000000032 
Value = 3400000000000000000000000000000033 
Value = 35000000000000000000000000000000034 
Value = 360000000000000000000000000000000035 
Value = 3700000000000000000000000000000000036 
Value = 38000000000000000000000000000000000037 
Value = 300000000000000000000000000000000000038 
Value = 340282366920938463463374607431768211455 
Listing9-9 terminated 

由于代码几乎与 i64toStr 相同(见清单 9-8),我会留给你来创建一个 128 位有符号整数转换函数;你只需提供 128 位取反和比较操作。作为提示,对于比较,只需检查 HO 字(高位字)是否设置了符号位。

9.1.6 格式化转换

前面章节中的代码通过使用最小的字符位置数将有符号和无符号整数转换为字符串。为了创建格式良好的数值表,你需要编写函数,在实际输出数字之前,先在数字字符串前添加适当的填充。等你有了这些“未格式化”版本的例程,实现格式化版本就很容易了。

第一步是编写 iSize 和 uSize 例程,计算显示值所需的最小字符位置数。实现此功能的一种算法类似于数字字符串转换例程。唯一的区别是,在进入例程时将计数器初始化为 0,并且在每次递归调用时增加该计数器,而不是输出一个数字。(如果数字为负,请不要忘记在 iSize 内部递增计数器;你必须考虑到负号的输出。)计算完成后,这些例程应返回操作数在 X0 寄存器中的大小。

然而,由于它使用了递归和除法,因此这种转换方案较慢。清单 9-10 展示了使用二分查找的暴力转换方法。

// Listing9-10.S 
//
// u64Size function: Computes the size 
// of an unsigned 64-bit integer (in 
// print positions) 

            #include    "aoaa.inc"

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-10"
fmtStr:     .asciz      "Value = %llu, size=%d\n"

// Values to test the u64Size function: 

dVals:      .dword      1 
            .dword      10 
            .dword      100 
            .dword      1000 
            .dword      10000 
            .dword      100000 
            .dword      1000000 
            .dword      10000000 
            .dword      100000000 
            .dword      1000000000 
            .dword      10000000000 
            .dword      100000000000 
            .dword      1000000000000 
            .dword      10000000000000 
            .dword      100000000000000 
            .dword      1000000000000000 
            .dword      10000000000000000 
 .dword      100000000000000000 
            .dword      1000000000000000000 
            .dword      10000000000000000000 
dCnt        =           (.-dVals) / 8 

            .code 
            .extern     printf 

// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

// u64Size 
//
//  Counts the number of output positions 
//  required for an integer-to-decimal-
//  string conversion 
//
//  Uses a binary search to quickly 
//  count the digits required by a value 
//
// Input: 
//  X1- Unsigned integer to count 
//
// Output: 
//  X1- Digit count 
//
// Table of digit counts and values: 
//
//   1: 1 
//   2: 10 
//   3: 100 
//   4: 1,000 
//   5: 10,000 
//   6: 100,000 
//   7: 1,000,000 
//   8: 10,000,000 
//   9: 100,000,000 
//  10: 1,000,000,000 
//  11: 10,000,000,000 
//  12: 100,000,000,000 
//  13: 1,000,000,000,000 
//  14: 10,000,000,000,000 
//  15: 100,000,000,000,000 
//  16: 1,000,000,000,000,000 
//  17: 10,000,000,000,000,000 
//  18: 100,000,000,000,000,000 
//  19: 1,000,000,000,000,000,000 
//  20: 10,000,000,000,000,000,000 

          ❶ proc    u64Size 
            stp     x0, x2, [sp, #-16]! 

 ❷ mov     x2, x1 
            ldr     x0, =1000000000 // 10: 1,000,000,000 
            cmp     x2, x0 
            bhs     ge10 

            ldr     x0, =10000 
            cmp     x2, x0 
            bhs     ge5 

            // Must be 1 to 4 digits here: 

            mov     x1, #1 
            cmp     x2, #1000 
            cinc    x1, x1, hs 
            cmp     x2, #100 
            cinc    x1, x1, hs 
            cmp     x2, #10 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// Must be 5 to 9 digits here: 

ge5:        ldr     x0, =1000000    // 7: 1,000,000 
            cmp     x2, x0 
            bhs     ge7 

            // Must be 5 or 6 digits: 

            mov     x1, #5 
            ldr     x0, =100000     // 6: 100,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// Must be 7 to 9 digits here: 

ge7:        mov     x1, #7 
            ldr     x0, =10000000   // 8: 10,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldr     x0, =100000000  // 9: 100,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// Handle 10 or more digits here: 

ge10:       ldr     x0, =100000000000000    // 15: 100,000,000,000,000 
            cmp     x2, x0 
            bhs     ge15 

 // 10 to 14 digits here: 

            ldr     x0, =1000000000000      // 13: 1,000,000,000,000 
            cmp     x2, x0 
            bhs     ge13 

            // 10 to 12 digits here: 

            mov     x1, #10 
            ldr     x0, =10000000000        // 11: 10,000,000,000 
            cmp     x2, x0 
          ❸ cinc    x1, x1, hs 
            ldr     x0, =100000000000       // 12: 100,000,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// 13 or 14 digits here: 

ge13:       mov     x1, #13 
            ldr     x0, =10000000000000     // 14: 10,000,000,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// 15 to 20 digits here: 

ge15:       ldr     x0, =100000000000000000 // 18: 100,000,000,000,000,000 
            cmp     x2, x0 
            bhs     ge18 

            // 15, 16, or 17 digits here: 

            mov     x1, #15 
            ldr     x0, =1000000000000000   // 16: 1,000,000,000,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldr     x0, =10000000000000000  // 17: 10,000,000,000,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldp     x0, x2, [sp], #16 
            ret 

// 18 to 20 digits here: 

ge18:       mov     x1, #18 
            ldr     x0, =1000000000000000000  // 19: 1,000,000,000,000,000,000 
            cmp     x2, x0 
            cinc    x1, x1, hs 
            ldr     x0, =10000000000000000000 // 20 digits 
            cmp     x2, x0 
            cinc    x1, x1, hs 
 ldp     x0, x2, [sp], #16 
            ret 
            endp    u64Size 

实际的 u64Size 函数❶使用二分查找算法快速扫描所有可能的值,以确定数字的位数。它通过将搜索空间对半分,比较输入值(移到 X2)与一个 10 位数字值❷来开始。在常规的二分查找方式中,代码的两部分将分别测试 1 到 9 位数字和 10 到 20 位数字。在这些范围内,搜索将(大致)反复对半分割,直到算法锁定确切的数字位数。当代码处理到 2 到 4 位数字时,它使用一些直线代码和一系列 cinc 指令,以快速处理最后几个案例,而无需执行分支❸。

这是构建命令和示例输出:

$ ./build Listing9-10 
$ ./Listing9-10 
Calling Listing9-10: 
Value = 1, size=1 
Value = 10, size=2 
Value = 100, size=3 
Value = 1000, size=4 
Value = 10000, size=5 
Value = 100000, size=6 
Value = 1000000, size=7 
Value = 10000000, size=8 
Value = 100000000, size=9 
Value = 1000000000, size=10 
Value = 10000000000, size=11 
Value = 100000000000, size=12 
Value = 1000000000000, size=13 
Value = 10000000000000, size=14 
Value = 100000000000000, size=15 
Value = 1000000000000000, size=16 
Value = 10000000000000000, size=17 
Value = 100000000000000000, size=18 
Value = 1000000000000000000, size=19 
Value = 10000000000000000000, size=20 
Listing9-10 terminated 

对于有符号整数,将清单 9-11 中的函数添加到清单 9-10 中的代码中(你可以在本书的可下载代码文件中找到完整的清单 9-11,地址是<wbr>artofarm<wbr>.randallhyde<wbr>.com)。

// Listing9-11.S 
//
// i64Size: 
//
// Computes the number of character positions that 
// the i64toStr function will emit 

 proc    i64Size 
            str     lr, [sp, #-16]! 

            cmp     x1, #0          // If less than zero, 
            bge     isPositive      // negate and treat 
                                    // like an uns64\. 
            neg     x1, x1 

            bl      u64Size 
            add     x1, x1, #1      // Adjust for "-". 
            ldr     lr, [sp], #16 
            ret 

isPositive: bl      u64Size 
            ldr     lr, [sp], #16 
            ret 
            endp    i64Size 

对于扩展精度大小操作,二分查找方法很快会变得难以处理(64 位已经足够糟糕了)。最佳解决方案是将你的扩展精度值除以一个 10 的幂(比如,1e+16)。这将把数字的大小减少 16 位数字。只要商大于 64 位,就重复这个过程,并跟踪你每次用 1e+16 进行除法操作时减少的位数。当商适合 64 位时(19 或 20 位),调用 64 位的 u64Size 函数,并加上你通过除法操作减少的数字位数(每次除以 1e+16 减少 16 位)。这个实现我留给你来完成。

一旦你有了 i64Size 和 u64Size 例程,编写格式化输出例程 u64toStrSize 或 i64toStrSize 就很简单了。初始进入时,这些例程会调用相应的 i64Size/u64Size 例程来确定数字所需的字符位置数。如果 i64Size/u64Size 例程返回的值大于或等于最小大小参数(传递给 u64toStrSize 或 i64toStrSize 的值),则无需其他格式化。如果参数 size 的值大于 i64Size/u64Size 返回的值,程序必须计算这两个值之间的差异,并在数字转换之前输出相应数量的空格(或其他填充字符)(假设是右对齐,这正是本章所介绍的)。

清单 9-12 展示了 utoStrSize/itoStrSize 函数(完整的源代码在线上可以找到);在这里,我省略了除了 utoStrSize/itoStrSize 函数本身之外的所有内容。

// Listing9-12.S (partial) 
//
// u64ToSizeStr 
//
//  Converts an unsigned 64-bit integer to 
//  a character string, using a minimum field 
//  width 
//
//  Inputs: 
//      X0- Pointer to buffer to receive string 
//
//      X1- Unsigned 64-bit integer to convert 
//          to a string 
//
//      X2- Minimum field width for the string 
//          (maximum value is 1,024). Note: if 
//          the minimum field width value is less 
//          than the actual output size of the 
//          integer, this function will ignore 
//          the value in X2 and use the correct 
//          number of output positions for the 
//          value. 
//
//  Outputs: 
//
//      Buffer- Receives converted characters. 
//              Buffer must be at least 22 bytes 
//              or X1 + 1 bytes long. 

          ❶ proc    u64ToStrSize 
            stp     x0, lr, [sp, #-16]! 
            stp     x1, x2, [sp, #-16]! 
            stp     x23, x24, [sp, #-16]! 
            stp     x25, x26, [sp, #-16]! 

            // Initialize x25 and x26 with 
            // appropriate functions to call: 

            lea     x25, u64Size 
            lea     x26, u64ToStr 

            b.al    toSizeStr 
            endp    u64ToStrSize 

/////////////////////////////////////////////////////
//
// i64ToStrSize: 
//
//  Just like u64ToStrSize, but handles signed integers 
//
//  Inputs: 
//      X0- Pointer to buffer to receive string 
//
//      X1- Signed 64-bit integer to convert 
//          to a string 
//
//      X2- Minimum field width for the string 
//          (maximum value is 1,024). Note: if 
//          the minimum field width value is less 
//          than the actual output size of the 
//          integer, this function will ignore 
//          the value in X2 and use the correct 
//          number of output positions for the 
//          value. 
//
//      Note:   Don't forget that if the number 
//              is negative, the '-' consumes 
//              an output position. 
//
//  Outputs: 
//      Buffer- Receives converted character. 
//              Buffer must be at least 22 bytes 
//              or X2 + 1 bytes long. 

          ❷ proc    i64ToStrSize 
            stp     x0, lr, [sp, #-16]! 
            stp     x1, x2, [sp, #-16]! 
            stp     x23, x24, [sp, #-16]! 
            stp     x25, x26, [sp, #-16]! 

            // Initialize x25 and x26 with 
            // appropriate functions to call: 

            lea     x25, i64Size 
            lea     x26, i64ToStr 

            b.al    toSizeStr   // Technically, this could just fall through. 
            endp    i64ToStrSize 

///////////////////////////////////////////////////////
//
// toSizeStr: 
//
//  Special function to handle signed and 
//  unsigned conversions for u64ToSize and i64ToSize 

          ❸ proc    toSizeStr 

            mov     x24, x1 // Save for now. 
          ❹ blr     x25     // Compute size of number. 

            // Compute difference between actual size 
            // and desired size. Set to the larger of 
            // the two: 

          ❺ cmp     x2, x1 
            csel    x23, x2, x1, ge 

            // Just as a precaution, limit the 
            // size to 1,024 characters (including 
            // the zero-terminating byte): 

            mov     x2, #1023   // Don't count 0 byte here. 
            cmp     x23, x2 
            csel    x23, x23, x2, ls 

 // Compute the number of spaces to emit before 
            // the first digit of the number: 

            subs    x23, x23, x1 
            beq     spacesDone 

            // Emit that many spaces to the buffer: 

          ❻ mov     x1, #0x2020 
            movk    x1, #0x2020, lsl #16 
            movk    x1, #0x2020, lsl #32 
            movk    x1, #0x2020, lsl #48 
            b.al    tst8 

            // Handle sequences of eight spaces: 

whl8:       str     x1, [x0], #8 
            sub     x23, x23, #8 
tst8:       cmp     x23, #8 
            bge     whl8 

            // If four to seven spaces, emit four 
            // spaces here: 

            cmp     x23, #4 
            blt     try2 
            str     w1, [x0], #4 
            sub     x23, x23, #4 

            // If two or three spaces, emit two 
            // here: 

try2:       cmp     x23, #2 
            blt     try1 
            strh    w1, [x0], #2 
            sub     x23, x23, #2 

            // If one space left, emit it here: 

try1:       cmp     x23, #1 
            blt     spacesDone 
            strb    w1, [x0], #1 

            // Okay, emit the digits here: 

spacesDone: mov     x1, x24 // Retrieve value. 
          ❼ blr     x26     // XXXToStr 

            ldp     x25, x26, [sp], #16 
            ldp     x23, x24, [sp], #16 
            ldp     x1, x2,   [sp], #16 
            ldp     x0, lr,   [sp], #16 
            ret 
            endp    toSizeStr 

///////////////////////////////////////////////////////
//
// printSize 
//
// Utility used by the main program to 
// compute sizes and print them 

          ❽ proc    printSize 

            locals  ps 
            dword   stk, 64 
            endl    ps 

            enter   ps.size 

            mov     x6, x1 
            lea     x0, buffer 
            blr     x27         // Call XXXToStrSize. 

            mov     x1, x6 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            lea     x3, buffer 
            mstr    x3, [sp, #16] 
            lea     x0, fmtStr 
            bl      printf 

            leave 
            endp    printSize 

values:     .dword  1, 10, 100, 1000, 10000, 100000, 1000000 
            .dword  10000000, 100000000, 1000000000, 10000000000 
            .dword  100000000000, 1000000000000, 10000000000000 
            .dword  100000000000000, 1000000000000000 
            .dword  10000000000000000, 100000000000000000 
            .dword  1000000000000000000, 10000000000000000000 
            .dword  0x7fffffffffffffff 
            .set    valSize, (.-values)/8 

negValues:  .dword  -1, -10, -100, -1000, -10000, -100000, -1000000 
            .dword  -10000000, -100000000, -1000000000, -10000000000 
            .dword  -100000000000, -1000000000000, -10000000000000 
            .dword  -100000000000000, -1000000000000000 
            .dword  -10000000000000000, -100000000000000000 
            .dword  -1000000000000000000, -10000000000000000000 
            .dword  0x8000000000000000 

sizes:      .word   5, 6, 7, 8, 9, 10, 15, 15, 15, 15 
            .word   20, 20, 20, 20, 20, 25, 25, 25, 25, 25, 30 

///////////////////////////////////////////////////////
//
// Here is the asmMain function: 

 ❾ proc    asmMain, public 

            locals  am 
            qword   am.x26x27 
            qword   am.x24x25 
            byte    am.stk, 64 
            endl    am 

            enter   am.size     // Activation record 
            stp     x26, x27, [fp, #am.x26x27] 
            stp     x24, x25, [fp, #am.x24x25] 

// Test unsigned integers: 

            lea     x27, u64ToStrSize 
            lea     x24, values 
            lea     x25, sizes 
            mov     x26, #valSize 
tstLp:      ldr     x1, [x24], #8 
            ldr     w2, [x25], #4 
            bl      printSize 
            subs    x26, x26, #1 
            bne     tstLp 

            lea     x27, i64ToStrSize 
            lea     x24, negValues 
            lea     x25, sizes 
            mov     x26, #valSize 
ntstLp:     ldr     x1, [x24], #8 
            ldr     w2, [x25], #4 
            bl      printSize 
            subs    x26, x26, #1 
            bne     ntstLp 

            ldp     x26, x27, [fp, #am.x26x27] 
            ldp     x24, x25, [fp, #am.x24x25] 
            leave 
            endp    asmMain 

u64toStrSize 函数❶只需加载 X25 和 X26 为适当的地址,并跳转到通用的 toSizeStr 函数以处理实际工作。i64ToStrSize 函数❷对有符号整数转换做同样的事情。

toSizeStr 函数❸处理了实际工作。首先,它调用适当的 toSize 函数(该函数的地址通过 X25 传递)来计算值所需的最小打印位置数❹。然后,它计算出需要多少个填充字符才能将数字右对齐到输出字段中❺。它在输出数字字符串❼之前,先输出所需数量的填充字符❻。值得注意的唯一一点是,代码尝试每次输出八个空格以提高性能,只要至少有八个填充字符,然后是四个,再是两个,最后是一个。

printSize 过程❽是一个小工具函数,asmMain 过程用它来显示值,而 asmMain 过程❾测试了 u64ToStrSize 和 i64ToStrSize 过程。

以下是清单 9-12 的构建命令和示例输出(请记住,实际的主程序仅出现在在线源代码中):

$ ./build Listing9-12 
$ ./Listing9-12 
Calling Listing9-12: 
                   1:   5='    1' 
                  10:   6='    10' 
                 100:   7='    100' 
                1000:   8='    1000' 
               10000:   9='    10000' 
              100000:  10='    100000' 
             1000000:  15='        1000000' 
            10000000:  15='       10000000' 
           100000000:  15='      100000000' 
          1000000000:  15='     1000000000' 
         10000000000:  20='         10000000000' 
        100000000000:  20='        100000000000' 
       1000000000000:  20='       1000000000000' 
      10000000000000:  20='      10000000000000' 
     100000000000000:  20='     100000000000000' 
    1000000000000000:  25='         1000000000000000' 
   10000000000000000:  25='        10000000000000000' 
  100000000000000000:  25='       100000000000000000' 
 1000000000000000000:  25='      1000000000000000000' 
-8446744073709551616:  25='     10000000000000000000' 
 9223372036854775807:  30='           9223372036854775807' 
                  -1:   5='   -1' 
                 -10:   6='   -10' 
                -100:   7='   -100' 
               -1000:   8='   -1000' 
              -10000:   9='   -10000' 
             -100000:  10='   -100000' 
            -1000000:  15='       -1000000' 
           -10000000:  15='      -10000000' 
          -100000000:  15='     -100000000' 
         -1000000000:  15='    -1000000000' 
        -10000000000:  20='        -10000000000' 
       -100000000000:  20='       -100000000000' 
      -1000000000000:  20='      -1000000000000' 
     -10000000000000:  20='     -10000000000000' 
    -100000000000000:  20='    -100000000000000' 
   -1000000000000000:  25='        -1000000000000000' 
  -10000000000000000:  25='       -10000000000000000' 
 -100000000000000000:  25='      -100000000000000000' 
-1000000000000000000:  25='     -1000000000000000000' 
 8446744073709551616:  25='      8446744073709551616' 
-9223372036854775808:  30='          -9223372036854775808' 
Listing9-12 terminated 

输出是值:大小 = '转换'。### 9.2 将浮点值转换为字符串

到目前为止,本章已经讨论了将整数数值转换为字符字符串(通常是输出给用户)。本节讨论了将浮点值转换为字符串,这同样非常重要。

将浮点值转换为字符串可以有两种形式:

  • 十进制表示法转换(例如 ±xxx.yyy 格式)

  • 指数(或科学)表示法转换(例如 ±x.yyyyye±zz 格式)

无论最终的输出格式如何,您需要进行两项不同的操作,将浮点值转换为字符字符串。首先,您必须将尾数转换为适当的数字字符串。其次,您需要将指数转换为数字字符串。

然而,这并不是将两个整数值简单地转换为十进制字符串并将它们连接在一起(在尾数和指数之间用 e)。首先,尾数不是一个整数值,它是一个定点分数二进制值。仅仅将它视为一个 n 位二进制值(其中 n 是尾数位数)几乎总会导致转换错误。其次,虽然指数或多或少是一个整数值,但它表示的是 2 的幂,而不是 10 的幂。将这个 2 的幂作为整数值表示并不适合十进制浮点表示。上述这两个问题(分数尾数和二进制指数)是将浮点值转换为字符串时的主要复杂性来源。

注意

指数实际上是一个带偏移的指数值。然而,这很容易转换为一个带符号的二进制整数。

双精度浮点值具有 53 位尾数(包括隐含位)。这不是一个 53 位的整数,而是这 53 位表示从 1.0 到略小于 2.0 之间的值。(有关 IEEE 64 位浮点格式的更多细节,请参见第 2.13 节“IEEE 浮点格式”,第 93 页。)双精度格式可以表示从 0 到大约 5 × 10^(–324) 的数字(使用标准化值时,大约为 ±1 × 10^(±308))。

为了以大约 16 位精度输出尾数的十进制形式,依次将浮点值乘以或除以 10,直到该数字的范围从 1e+15 到略小于 1e+16(即 9.9999 … e+15)。一旦指数进入适当的范围,尾数位就形成一个 16 位整数值(没有小数部分),可以将其转换为十进制字符串,得到构成尾数值的 16 位数字。

为了将指数转换为适当的十进制字符串,跟踪乘以或除以 10 的次数。每次除以 10 时,十进制指数值加 1;每次乘以 10 时,十进制指数值减 1。完成该过程后,从十进制指数值中减去 16(因为这个过程产生的值其指数为 16),然后将十进制指数值转换为字符串。

以下各节中的转换假设你始终希望生成一个具有 16 位有效数字的尾数。要生成格式化输出并且有效数字少,请参见第 9.2.4 节,“双精度值转换为字符串”,下一页。

9.2.1 将浮点指数转换为十进制数字字符串

为了将指数转换为十进制数字字符串,使用以下算法:

1.  如果数字是 0.0,直接生成尾数输出字符串“0000000000000000”(注意字符串开头的空格),将指数设置为 0,完成。否则,继续执行以下步骤。

2.  将十进制指数初始化为 0。

3.  如果指数为负数,输出一个连字符(-)并将值取负;如果指数为正数,输出一个空格字符。

4.  如果(可能是取负的)指数值小于 1.0,则跳到第 8 步。

5.  正指数:将数字与逐渐较小的 10 的幂进行比较,从 10^(+256)开始,然后是 10^(+128),然后是 10^(+64),然后……最后是 10⁰。每次比较后,如果当前值大于该 10 的幂,则除以该 10 的幂,并将该 10 的幂指数(256、128、……、0)加到十进制指数值中。

6.  重复第 5 步,直到指数为 0(即值的范围为 1.0 ≤ value < 10.0)。

7.  跳到第 10 步。

8.  负指数:将数字与从 10^(–256)开始的逐渐较大的 10 的幂进行比较,然后是 10^(–128),然后是 10^(–64),然后……最后是 10⁰。每次比较后,如果当前值小于该 10 的幂,则除以该 10 的幂,并将该 10 的幂指数(256、128、……、0)从十进制指数值中减去。

9.  重复第 8 步,直到指数为 0(即值的范围为 1.0 ≤ value < 10.0)。

10.  此时,指数值是一个合理的数字,可以通过使用标准的无符号到字符串转换将其转换为整数值(参见第 9.1.3 节,“无符号十进制值转换为字符串”,第 495 页)。

9.2.2 将浮点尾数转换为数字字符串

为了将尾数转换为数字字符串,不能简单地将前一节中产生的 53 位尾数当作整数值来处理,因为它仍然表示一个从 1.0 到小于 2.0 的整数。然而,如果将该浮点值(已转换为从 1.0 到略小于 10.0 的值)乘以 10^(+15),则实际上会生成一个整数,并且尾数的数字会向左移动 15 个打印位置(16 位数字是双精度值能够输出的数字位数)。然后可以将这个“整数”转换为字符串。结果将包括 16 个尾数字符。要将尾数转换为字符串,请执行以下步骤:

1.  将前一节中指数计算得到的值乘以 1e+15。这会产生一个数字,将小数位左移 15 个打印位置。

2.  获取 52 位尾数,并将一个隐式的 52 位比特设置为 1,然后将该 53 位值进行零扩展到 64 位。

3.  通过使用本章早些时候介绍的无符号整数到字符串的函数,将结果 64 位值转换为字符串(参见第 9.1.3 节“无符号十进制值到字符串”,在第 495 页)。

9.2.3 十进制和指数格式的字符串

要生成一个十进制字符串(而不是指数形式的数字),剩下的任务是将小数点正确地放置在数字字符串中。如果指数大于或等于 0,需要将小数点插入到位置指数 + 1,位置从前一节中生成的第一个尾数字符开始。例如,如果尾数转换结果为 1234567890123456,且指数为 3,则需要在索引 4 的位置(3 + 1)前插入小数点,结果将是 1234.567890123456。

如果指数大于 16,则在字符串末尾插入指数 – 16 个零字符(或者如果不希望允许将大于 1e+16 的值转换为十进制形式,则返回错误)。如果指数小于 0,则在数字字符串前插入 0.,后跟absexp) – 1 个零字符。如果指数小于–16(或其他任意值),你可能选择返回错误或自动切换到指数形式。

生成指数输出比生成十进制输出稍微容易一些。始终在转换后的尾数字符串中的第一个和第二个字符之间插入小数点,然后在字符串后面加上 e±xxx,其中±xxx 是指数值的字符串转换。例如,如果尾数转换结果为 1234567890123456,且指数为–3,那么生成的字符串将是 1.234567890123456e-003(注意指数数字前的 0)。

9.2.4 双精度值转换为字符串

本节展示了将双精度值转换为字符串的代码,可以是十进制或指数形式,分别为这两种输出格式提供了不同的函数。由于列表 9-13 比较长,我已将其拆分为多个部分,并对每个部分进行了注释。

// Listing9-13.S 
//
// Floating-point (double) to string conversion 
//
// Provides both exponential (scientific notation) 
// and decimal output formats 
            #include    "aoaa.inc"

          ❶ .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-13"
fmtStr1:    .asciz      "r64ToStr: value='%s'\n"
fmtStr2:    .asciz      "fpError: code=%lld\n"
fmtStr3:    .asciz      "e64ToStr: value='%s'\n"
newlines:   .asciz      "\n\n"
expStr:     .asciz      "\n\nTesting e64ToStr:\n\n"

// r10str_1: A global character array that will 
// hold the converted string 

          ❷ .data 
r64str_1:   .space      32, 0 

            .code 
            .extern     printf 

// tenTo15: Used to multiply a value from 1.0 
// to less than 2.0 in order to convert the mantissa 
// to an actual integer 

❸ tenTo15:    .double     1.0e+15 

// potPos, potNeg, and expTbl: 
//
// Power of 10s tables (pot) used to quickly 
// multiply or divide a floating-point value 
// by powers of 10\. expTbl is the power-of-
// 10 exponent (absolute value) for each of
// the entries in these tables. 

❹ potPos:     .double     1.0e+0 
            .double     1.0e+1 
            .double     1.0e+2 
            .double     1.0e+4 
            .double     1.0e+8 
            .double     1.0e+16 
            .double     1.0e+32 
            .double     1.0e+64 
            .double     1.0e+128 
            .double     1.0e+256 
expCnt      =           (.-potPos) / 8 

potNeg:     .double     1.0e-0 
            .double     1.0e-1 
            .double     1.0e-2 
            .double     1.0e-4 
            .double     1.0e-8 
            .double     1.0e-16 
            .double     1.0e-32 
 .double     1.0e-64 
            .double     1.0e-128 
            .double     1.0e-256 

expTbl:     .dword      0 
            .dword      1 
            .dword      2 
            .dword      4 
            .dword      8 
            .dword      16 
            .dword      32 
            .dword      64 
            .dword      128 
            .dword      256 

// Maximum number of significant digits for 
// a double-precision value: 

❺ maxDigits   =           16 

// Return program title to C++ program: 

          ❻ proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

像本章中的示例程序一样,列表 9-13 以一个只读数据部分 ❶ 开头,其中包含程序的标题字符串和主程序中 printf() 调用所使用的各种格式字符串。此程序中的唯一数据变量是 r64str_1 ❷,它是一个 32 字节的字符字符串,用于存放转换后的字符串。程序负责确保所有转换都能适应 32 字节的空间。

列表 9-13 将几个只读常量放置在 .code 部分,以便程序可以通过使用相对 PC 定址模式直接访问这些常量(而不是使用多个指令获取对象的地址并间接访问它)。第一个这样的常量是 tenTo15 ❸,它保存值 1.0e+15\。转换代码使用这个常量将范围在 1.0 到略小于 10.0 之间的浮动点值乘以 1e+15,从而在将尾数转换为整数值时获得略小于 1e+16 的值。

potPos、potNeg 和 expTbl 表 ❹ 包含用于将浮动点值乘以不同 10 的幂次的正负 10 的幂次(pot)表,用于将值处理到 1.0 到 10.0 的范围内。expTbl 包含与 potPos 和 potNeg 表中的相同条目对应的指数的绝对值。代码在将尾数转换到 1.0 到 10.0 的范围时,会将此值加到或从累积的小数指数中减去。

maxDigits 清单常量 ❺ 指定了该转换代码支持的有效数字的数量(对于双精度浮动点数是 16 位)。最后,这段代码包含了无处不在的 getTitle 函数 ❻,它返回程序标题字符串的地址,供 C++ shell 代码使用。

以下代码将浮动点值转换为字符串:

// Listing9-13.S (cont.) 
//
// u53toStr 
//
//  Converts a 53-bit unsigned integer to a string containing 
//  exactly 16 digits (technically, it does 64-bit arithmetic, 
//  but is limited to 53 bits because of the 16-digit output 
//  format) 
//
// Inputs: 
//  X0-     Pointer to buffer to receive string 
//  X1-     Unsigned 53-bit integer to convert 
//
// Outputs: 
//  Buffer- Receives the zero-terminated string 
//  X0-     Points at zero-terminating byte in string 
//
//  Buffer must have at least 17 bytes allocated for it. 
//
// This code is a bit simplified from the u64toStr function 
// because it always emits exactly 16 digits 
// (never any leading 0s). 

          ❶ proc    u53toStr 

            stp     x1, x2, [sp, #-16]! 
            stp     x3, x4, [sp, #-16]! 
            str     x5, [sp, #-16]! 

            mov     x4, #10     // Mul/div by 10 using X4 
            mov     x5, xzr     // Holds string of 8 chars 

            // Handle LO digit here. Note that the LO 
            // digit will ultimately be moved into 
            // bit positions 56-63 of X5 because numeric 
            // strings are, intrinsically, big-endian (with 
            // the HO digit appearing first in memory). 

          ❷ udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // The following is an unrolled loop 
            // (for speed) that processes the 
            // remaining 15 digits. 
            // 
 // Handle digit 1 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 2 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 3 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 4 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 5 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 6 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 7 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Store away LO 8 digits: 

            str     x5, [x0, #8] 
            mov     x5, xzr 

 // Handle digit 8 here: 

          ❸ udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 9 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 10 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 11 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 12 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 13 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 14 here: 

            udiv    x2, x1, x4      // X2 = quotient 
            msub    x3, x2, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            // Handle digit 15 here: 

            udiv    x1, x2, x4      // X1 = quotient 
            msub    x3, x1, x4, x2  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

 // Store away HO 8 digits: 

            str     x5, [x0] 
            strb    wzr, [x0, #maxDigits]!  // Zero-terminating byte 

            ldr     x5, [sp], #16 
            ldp     x3, x4, [sp], #16 
            ldp     x1, x2, [sp], #16 
            ret 
            endp    u53toStr 

u53ToStr 函数 ❶ 负责将一个 53 位无符号整数转换为一个恰好包含 16 位数字的字符串。理论上,代码可以使用来自列表 9-12 的 u64toSizeStr 函数将 53 位的值(零扩展为 64 位)转换为字符串。然而,浮动点尾数转换为字符串时总是会生成一个 16 字符的字符串(如果需要,会有前导零),因此十进制整数到字符串的转换比 u64toSizeStr 函数更高效,后者可能会生成可变长度的字符串。为了优先节省空间,如果你的代码中已经使用了 u64toSizeStr 函数,你可以去掉 u53ToStr,改为调用 u64toSizeStr(并指定 '0' 作为填充字符)。

u53ToStr 使用的转换算法非常直接且暴力:它将低 8 位数字转换为 8 个字符的序列并输出❷,然后将高 8 位数字转换为 8 个字符的序列并输出❸。在这两种情况下,它都使用除以 10 和除以 10 的余数算法将每个数字转换为字符(更多细节请参考 Listing 9-6 中的 u64ToStr 讨论)。

此函数由 FPDigits 使用,将尾数转换为十进制数字字符串:

// Listing9-13.S (cont.) 
//
// FPDigits 
//
//  Used to convert a floating-point value 
//  in D0 to a string of digits 
//
// Inputs: 
//  D0-     Double-precision value to convert 
//  X0-     Pointer to buffer to receive chars 
//
// Outputs: 
//  X0-     Still points at buffer 
//  X1-     Contains exponent of the number 
//  X2-     Contains sign (space or '-') 

            proc    FPDigits 
            str     lr,       [sp, #-16]! 
            str     d0,       [sp, #-16]! 
            stp     d1, d2,   [sp, #-16]! 
            stp     x22, x23, [sp, #-16]! 
 stp     x24, x25, [sp, #-16]! 
            stp     x26, x27, [sp, #-16]! 

            mov     x2, #' '        // Assume sign is +. 

#define fp1 d2                      // D2 holds 1.0\. 

            fmov    fp1, #1.0 

             // Special case for 0.0: 

          ❶ fcmp    d0, #0.0 
            bne     d0not0 

            // Check for -0.0: 

          ❷ fmov    x1, d0 
            ands    x1, x1, #0x8000000000000000 
            beq     posZero 
            mov     x2, #'-' 

posZero: 
            mov     x1, #0x3030 
            movk    x1, #0x3030, lsl #16 
            movk    x1, #0x3030, lsl #32 
            movk    x1, #0x3030, lsl #48 
            str     x1, [x0] 
            str     x1, [x0, #8] 
            mov     x1, #0          // Exponent = 0 

            // For debugging purposes, zero-terminate this 
            // string (the actual code just grabs 16 bytes, 
            // so this isn't strictly necessary): 

            strb    w0, [x0, #16] 
            b.al    fpdDone 

// If the number is nonzero, deal with it here. Note 
// that the flags were set by comparing D0 to 0.0 earlier. 

❸ d0not0:     bge     fpIsPositive    // See if positive or negative. 

            // If negative, negate and change the sign 
            // character to '-'. 

            fabs    d0, d0 
            mov     x2, #'-' 

// Get the number from 1.0 to <10.0 so you can figure out 
// what the exponent is. Begin by checking to see if you have 
// a positive or negative exponent. 

fpIsPositive: 
            mov     x1, xzr         // Initialize exponent. 
 ❹ fcmp    d0, fp1 
            bge     posExp 

            // The value is in the range 0.0 to 1.0, 
            // exclusive, at this point. That means this 
            // number has a negative exponent. Multiply 
            // the number by an appropriate power of 10 
            // until you get it in the range 1 through 10\. 

            lea     x27, potNeg 
            lea     x26, potPos 
            lea     x25, expTbl 
            mov     x24, #expCnt 

// Search through the potNeg table until you find a power 
// of 10 that is less than the value in D0: 

cmpNegExp: 
          ❺ subs    x24, x24, #1 
            blt     test1       // Branch if X24 < 1\. 

            ldr     d1, [x27, x24, lsl #3]  // D1 = potNeg[X24 * 8] 
            fcmp    d1, d0      // Repeat while 
            ble     cmpNegExp   // table <= value. 

            // Eliminate the current exponent indexed by 
            // X24 by multiplying by the corresponding 
            // entry in potPos: 

            ldr     x22, [x25, x24, lsl #3] // X22 = expTbl[X24 * 8] 
            sub     x1, x1, x22 
            ldr     d1, [x26, x24, lsl #3]  // D1 = potPos[X24 * 8] 
            fmul    d0, d0, d1 
            b.al    cmpNegExp 

// If you get to this point, you've indexed through 
// all the elements in the potNeg and it's time to stop. 
//
// If the remainder is *exactly* 1.0, you can branch 
// on to InRange1_10; otherwise, you still have to multiply 
// by 10.0 because you've overshot the mark a bit. 

test1:      fcmp    d0, fp1 
            beq     inRange1_10 

            fmov    d1, #10.0 
            fmul    d0, d0, d1 
            sub     x1, x1, #1      // Decrement exponent. 
            b.al    inRange1_10 

// At this point, you have a number that is 1 or greater. 
// Once again, your task is to get the value from 1.0 to <10.0\. 

posExp: 
            lea     x26, potPos 
            lea     x25, expTbl 
            mov     x24, #expCnt 

❻ cmpPosExp:  subs    x24, x24, #1 
            blt     inRange1_10     // If X24 < 1 

            ldr     d1, [x26, x24, lsl #3]  // D1 = potPos[X24 * 8] 
            fcmp    d1, d0 
            bgt     cmpPosExp 

            ldr     x22, [x25, x24, lsl #3] // X22 = expTbl[X24 * 8] 
            add     x1, x1, x22 
            fdiv    d0, d0, d1 
            b.al    cmpPosExp 

// Okay, at this point the number is in the range 1 <= x < 10\. 
// Let's multiply it by 1e+15 to put the most significant digit 
// into the 16th print position, then convert the result to 
// a string and store away in memory. 

❼ inRange1_10: 
            ldr     d1, tenTo15 
            fmul    d0, d0, d1 
            fcvtau  x22, d0     // Convert to unsigned integer. 

            // Convert the integer mantissa to a 
            // string of digits: 

            stp     x0, x1, [sp, #-16]! 
            mov     x1, x22 
            bl      u53toStr 
            ldp     x0, x1, [sp], #16 

fpdDone: 
            ldp     x26, x27,   [sp], #16 
            ldp     x24, x25,   [sp], #16 
            ldp     x22, x23,   [sp], #16 
            ldp     d1, d2,     [sp], #16 
            ldr     d0,         [sp], #16 
            ldr     lr,         [sp], #16 
            ret 
            endp    FPDigits 

FPDigits 将任意的双精度尾数转换为十进制数字字符串。它假定要转换的浮点值存储在 D0 寄存器中,且 X0 包含指向将存储字符串转换结果的缓冲区的指针。该函数还将二进制(2 的幂次)指数转换为十进制整数,并将指数值返回至 X1 寄存器,将值的符号(空格字符表示非负值,或'-')返回至 X2 寄存器。

FPDigits 首先检查特殊情况 0.0❶。如果 D0 包含 0,该函数将字符串缓冲区初始化为 0000000000000000(16 个 0 字符),并返回,X0 包含 0,X2 包含空格字符。代码会检查特殊情况-0.0,如果结果为-0.0,则返回 X2 包含负号❷。接下来,FPDigits 检查浮点值的符号,并根据需要将 X2 设置为'-'❸。代码还将十进制指数累加器(保存在 X0 中)初始化为 0。

在设置符号后,FPDigits 函数检查浮点值的指数,看看它是正数还是负数❹。代码分别处理正指数和负指数的值。如果指数为负,cmpNegExp 循环会通过 potNeg 表查找大于 D0 中值的项❺。当循环找到这样的值时,它将 D0 乘以 potNeg 中的该项,然后从 X1 中存储的十进制指数值中减去 expTbl 中相应的项。cmpNegExp 循环会重复这个过程,直到 D0 中的值大于 1.0。每当结果不大于 1.0 时,代码会将 D0 中的值乘以 10.0,因为代码需要调整之前发生的 0.1 的乘法。另一方面,如果指数为正❻,cmpPosExp 循环则做同样的工作,但会除以 potPos 表中的项,并将 expTbl 中相应的项加到 X1 中存储的十进制指数值。

一旦 cmpPosExp 或 cmpNegExp 循环将值调整到 1.0 到接近 10.0 的范围,它会将该值乘以 10¹⁵,并将其转换为整数(存储在 X22 中)❼。然后,FPDigits 调用 u53toStr 函数将此整数转换为一个精确的 16 位数字字符串。该函数将符号字符(非负值为空格,负值为'-')返回至 X2,十进制指数返回至 X1。

请注意,FPDigits 仅将尾数转换为数字字符串。这是 r64ToStr 和 e64ToStr 函数的基础代码,用于将浮点值转换为可识别的字符串。在介绍这些函数之前,有一个实用函数需要解释:chkNaNINF。

某些浮点操作会产生无效结果。IEEE 754 浮点标准定义了三种特殊值来表示这些无效结果:NaN(非数字)、+INF(正无穷大)和-INF(负无穷大)。由于 ARM 浮点硬件可能会产生这些结果,因此浮点到字符串的转换必须处理这三种特殊值。NaN、+INF 和-INF 的指数值都包含 0x7FF(没有其他有效值使用此指数)。如果指数是 0x7FF 且尾数位全为 0,则值为+INF 或-INF(由符号位决定)。如果尾数非零,则值为 NaN(符号位可以忽略)。chkNaNINF 函数会检查这些值,并在数字无效时输出字符串 NaN、INF 或-INF:

// Listing9-13.S (cont.) 
//
// chkNaNINF 
//
// Utility function used by r64ToStr and e64ToStr to check 
// for NaN and INF 
//
// Inputs: 
//  D0-     Number to check against NaN and INF 
//  X19-    Field width for output 
//  X21-    Fill character 
//  X22-    (outBuf) Pointer to output buffer 
//  X25-    Return address to use if number is invalid 
//
// Outputs: 
//  Buffer- Will be set to the string NaN, INF, 
//          or -INF if the number is not valid 
//
//  Note: Modifies value in X0 

            proc    chkNaNINF 

            // Handle NaN and INF special cases: 

          ❶ fmov    x0, d0 
            lsr     x0, x0, #52 
            and     x0, x0, #0x7ff 
            cmp     x0, #0x7ff 
            blo     notINFNaN 

            // At this point, it's NaN or INF. INF has a 
            // mantissa containing 0, NaN has a nonzero 
            // mantissa: 

          ❷ fmov    x0, d0 
            ands    x0, x0, #0x000fffffffffffff 
            beq     isINF 

            // Is NaN here: 

          ❸ ldr     w0, ='N' + ('a' << 8) + ('N' << 16) 
            str     w0, [x22] 
            mov     x0, #3 
            b.al    fillSpecial 

            // INF can be positive or negative. Must output a 
            // '-' character if it is -INF: 

❹ isINF:      fmov    x0, d0 
            ands    x0, x0, #0x8000000000000000 // See if -INF. 
            bne     minusINF 

            ldr     w0, ='I' + ('N' << 8) + ('F' << 16) 
            str     w0, [x22] 
            mov     x0, #3 
            b.al    fillSpecial 

❺ minusINF:   ldr     w0, ='-' + ('I' << 8) + ('N' << 16) + ('F' << 24) 
            str     w0, [x22] 
 strb    wzr, [x22, #4] 
            mov     x0, #4 

// For NaN and INF, fill the remainder of the string, as appropriate: 

❻ fillSpecial: 
            b.al    whlLTwidth 

fsLoop:     strb    w21, [x22, x0] 
            add     x0, x0, #1 
 whlLTwidth: 
            cmp     x0, x19 
            blo     fsLoop 
          ❼ mov     lr, x25         // Return to alternate address. 

notINFNaN:  ret 
            endp    chkNaNINF 

代码将 D0 中的浮点值移动到 X0,然后检查指数位是否包含 0x7FF ❶。如果指数位不包含此值,过程将返回给调用者(使用 LR 中的返回地址)。

如果指数位是 0x7FF,代码会检查尾数以判断其是 0 还是非零 ❷。如果是非零,代码会将字符字符串 NaN 输出到由 X22 指向的缓冲区 ❸。如果尾数非零,代码会检查符号位是否被设置 ❹。如果没有,代码会将 INF 输出到输出缓冲区。如果符号位被设置,代码会将-INF 输出到输出缓冲区 ❺。

在所有三种情况下(NaN、INF 或-INF),代码会转移到 fillSpecial ❻,在那里它添加足够的填充字符(填充字符在 W21 中,字段宽度在 X19 中)。此时,代码不会返回给调用者,而是将控制权转移到 X25 中保存的地址 ❼。调用者(r64ToStr 或 e64ToStr)在调用 chkNaNINF 之前将无效值返回地址加载到 X25 中。我本可以设置一个标志,比如进位标志,并在返回时测试它。然而,我想展示另一种实现方法,这种方法稍显优雅(尽管可以说不那么易读)。

在 chkNaNINF 处理完毕后,是时候看看用户调用的 r64ToStr 函数,它将浮点值转换为字符串:

// Listing9-13.S (cont.) 
//
// r64ToStr 
//
// Converts a REAL64 floating-point number to the 
// corresponding string of digits. Note that this 
// function always emits the string using decimal 
// notation. For scientific notation, use the e10ToBuf 
// routine. 
//
// On entry: 
//
//  D0-         (r64) Real64 value to convert 
//
//  X0-         (outBuf) r64ToStr stores the resulting 
//              characters in this string. 
//
//  X1-         (fWidth) Field width for the number (note 
//              that this is an *exact* field width, not a 
//              minimum field width) 
//
//  X2-         (decDigits) # of digits to display after the 
//              decimal pt 
//
//  X3-         (fill) Padding character if the number of 
//              digits is smaller than the specified field 
//              width 
//
//  X4-         (maxLength) Maximum string length 
//
// On exit: 
//
// Buffer contains the newly formatted string. If the 
// formatted value does not fit in the width specified, 
// r64ToStr will store "#" characters into this string. 
//
// Carry-    Clear if success, set if an exception occurs. 
//           If width is larger than the maximum length of 
//           the string specified by buffer, this routine 
//           will return with the carry set. 
//
//***********************************************************

            proc    r64ToStr 

            // Local variables: 

            locals  rts 
            qword   rts.x0x1 
            qword   rts.x2x3 
            qword   rts.x4x5 
            qword   rts.x19x20 
            qword   rts.x21x22 
            qword   rts.x23x24 

            dword   rts.x25 
            byte    rts.digits, 80 
            byte    rts.stk, 64 
            endl    rts 

            enter   rts.size 

            // Use meaningful names for the nonvolatile 
            // registers that hold local/parameter values: 

            #define fpVal d0 
            #define fWidth x19      // chkNaNINF expects this here. 
 #define decDigits x20 
            #define fill w21        // chkNaNINF expects this here. 
            #define outBuf x22      // chkNaNINF expects this here. 
            #define maxLength x23 
            #define exponent x24 
            #define sign w25 
            #define failAdrs x25    // chkNaNINF expects this here. 

            // Preserve registers: 

            stp     x0,   x1, [fp, #rts.x0x1] 
            stp     x2,   x3, [fp, #rts.x2x3] 
            stp     x4,   x5, [fp, #rts.x4x5] 
            stp     x19, x20, [fp, #rts.x19x20] 
            stp     x21, x22, [fp, #rts.x21x22] 
            stp     x23, x24, [fp, #rts.x23x24] 
            str     x25,      [fp, #rts.x25] 

            // Move parameter values to nonvolatile 
            // storage: 

            mov     outBuf, x0 
            mov     fWidth, x1 
            mov     decDigits, x2 
            mov     fill, w3 
            mov     maxLength, x4 

            // First, make sure the number will fit into 
            // the specified string. 

            cmp     fWidth, maxLength 
            bhs     strOverflow 

            // If the width is 0, return an error: 

            cmp     fWidth, #0 
            beq     valOutOfRange 

            // Handle NaN and INF special cases. 
            // Note: if the value is invalid, control 
            // transfers to clcAndRet rather than simply 
            // returning. 

          ❶ lea     failAdrs, clcAndRet 
            bl      chkNaNINF 

            // Okay, do the conversion. Begin by 
            // processing the mantissa digits: 

            add     x0, fp, #rts.digits // lea x0, rts.digits 
          ❷ bl      FPDigits            // Convert r64 to string. 
            mov     exponent, x1        // Save away exponent result. 
            mov     sign, w2            // Save mantissa sign char. 

// Round the string of digits to the number of significant 
// digits you want to display for this number. Note that 
// a maximum of 16 digits are produced for a 53-bit value. 

          ❸ cmp     exponent, #maxDigits 
            ble     dontForceWidthZero 
            mov     x0, xzr         // If the exponent is negative or 
                                    // too large, set width to 0\. 
dontForceWidthZero: 
            add     x2, x0, decDigits // Compute rounding position. 
            cmp     x2, #maxDigits 
            bhs     dontRound       // Don't bother if a big #. 

            // To round the value to the number of 
            // significant digits, go to the digit just 
            // beyond the last one you are considering (X2 
            // currently contains the number of decimal 
            // positions) and add 5 to that digit. 
            // Propagate any overflow into the remaining 
            // digit positions. 

            add     x2, x2, #1          // Index + 1 of last sig digit 
            ldrb    w0, [x1, x2]        // Get that digit. 

            add     w0, w0, #5          // Round (for example, +0.5) 
            cmp     w0, #'9' 
            bls     dontRound 

            mov     x0, #('0' + 10)     // Force to 0\. 
whileDigitGT9: 
            sub     w0, w0, #10         // Sub out overflow, 
            strb    w0, [x1, x2]        // carry, into prev 
            subs    x2, x2, #1          // digit (until first 
            bmi     hitFirstDigit       // digit in the #). 

            ldrb    w0, [x1, x2]        // Increment previous 
            add     w0, w0, #1          // digit. 
            strb    w0, [x1, x2] 

            cmp     w0, #'9'            // Overflow if > '9' 
            bhi     whileDigitGT9 
            b.al    dontRound 

hitFirstDigit: 

            // If you get to this point, you've hit the 
            // first digit in the number, so you have to 
            // shift all the characters down one position 
            // in the string of bytes and put a "1" in the 
            // first character position. 

          ❹ mov     x2, #maxDigits      // Max digits in value 
repeatUntilX2eq0: 

 ldrb    w0, [x1, x2] 
            add     x2, x2, #1 
            strb    w0, [x1, x2] 
            subs    x2, x2, #2 
            bne     repeatUntilX2eq0 

            mov     w0, #'1' 
            strb    w0, [x1, x2] 

            add     exponent, exponent, #1 // Increment exponent because 
                                           // you added a digit. 

dontRound: 

            // Handle positive and negative exponents separately. 

          ❺ mov     x5, xzr             // Index into output buf. 
            cmp     exponent, #0 
            bge     positiveExponent 

            // Negative exponents: 
            // Handle values from 0 to 1.0 here (negative 
            // exponents imply negative powers of 10). 
            // 
            // Compute the number's width. Since this 
            // value is from 0 to 1, the width 
            // calculation is easy: it's just the number of 
            // decimal positions they've specified plus 
            // 3 (since you need to allow room for a 
            // leading "-0."). X2 = number of digits to emit 
            // after "." 

            mov     x4, #4 
            add     x2, decDigits, #3 
            cmp     x2, x4 
            csel    x2, x2, x4, hs  // If X2 < X4, X2 = X4 

            cmp     x2, fWidth 
            bhi     widthTooBig 

            // This number will fit in the specified field 
            // width, so output any necessary leading pad 
            // characters. X3 = number of padding characters 
            // to output. 

          ❻ sub     x3, fWidth, x2 
            b.al    testWhileX3ltWidth 

whileX3ltWidth: 
            strb    fill, [outBuf, x5] 
            add     x5, x5, #1          // Index 
            add     x2, x2, #1          // Digits processed 
testWhileX3ltWidth: 
            cmp     x2, fWidth 
            blo     whileX3ltWidth 

 // Output " 0." or "-0.", depending on 
            // the sign of the number: 

            strb    sign, [outBuf, x5] 
            add     x5, x5, #1 
            mov     w0, #'0' 
            strb    w0, [outBuf, x5] 
            add     x5, x5, #1 
            mov     w0, #'.' 
            strb    w0, [outBuf, x5] 
            add     x5, x5, #1 
            add     x3, x3, #3 

            // Now output the digits after the decimal point: 

            mov     x2, xzr             // Count the digits here. 
            add     x1, fp, #rts.digits // lea x1, rts.digits 

// If the exponent is currently negative, or if 
// you've output more than 16 significant digits, 
// just output a 0 character. 

repeatUntilX3geWidth: 
            mov     x0, #'0' 
            adds    exponent, exponent, #1 
            bmi     noMoreOutput 

            cmp     x2, #maxDigits 
            bge     noMoreOutput 

            ldrb    w0, [x1] 
            add     x1, x1, #1 

noMoreOutput: 
            strb    w0, [outBuf, x5] 
            add     x5, x5, #1          // Index 
            add     x2, x2, #1          // Digits processed 
            add     x3, x3, #1          // Digit count 
            cmp     x3, fWidth 
            blo     repeatUntilX3geWidth 
            b.al    r64BufDone 

// If the number's actual width was bigger than the width 
// specified by the caller, emit a sequence of '#' characters 
// to denote the error. 

❼ widthTooBig: 

            // The number won't fit in the specified field 
            // width, so fill the string with the "#" 
            // character to indicate an error. 

            mov     x2, fWidth 
            mov     w0, #'#' 
fillPound:  strb    w0, [outBuf, x5] 
            add     x5, x5, #1          // Index 
            subs    x2, x2, #1 
            bne     fillPound 
            b.al    r64BufDone 

// Handle numbers with a positive exponent here. 
//
// Compute # of print positions consumed by output string. 
// This is given by: 
//
//                   Exponent     // # of digits to left of "." 
//           +       2            // Sign + 1's digit 
//           +       decDigits    // Add in digits right of "." 
//           +       1            // If there is a decimal point 

❽ positiveExponent: 

            mov     x3, exponent    // Digits to left of "." 
            add     x3, x3, #2      // sign posn 
            cmp     decDigits, #0   // See if any fractional 
            beq     decPtsIs0       // part. 

            add     x3, x3, decDigits // Digits to right of "." 
            add     x3, x3, #1        // Make room for the "." 

decPtsIs0: 

            // Make sure the result will fit in the 
            // specified field width. 

            cmp     x3, fWidth 
            bhi     widthTooBig 
            beq     noFillChars 

            // If the actual number of print positions 
            // is less than the specified field width, 
            // output leading pad characters here. 

            subs    x2, fWidth, x3 
            beq     noFillChars 

fillChars:  strb    fill, [outBuf, x5] 
            add     x5, x5, #1 
            subs    x2, x2, #1 
            bne     fillChars 

noFillChars: 

            // Output the sign character: 

            strb    sign, [outBuf, x5] 
            add     x5, x5, #1 

 // Okay, output the digits for the number here: 

            mov     x2, xzr             // Counts # of output chars 
            add     x1, fp, #rts.digits // lea x1, rts.digits 

            // Calculate the number of digits to output 
            // before and after the decimal point: 

            add     x3, decDigits, exponent 
            add     x3, x3, #1          // Always one digit before "." 

// If we've output fewer than 16 digits, go ahead 
// and output the next digit. Beyond 16 digits, 
// output 0s. 

repeatUntilX3eq0: 

            mov     w0, #'0' 
            cmp     x2, #maxDigits 
            bhs     putChar 

            ldrb    w0, [x1] 
            add     x1, x1, #1 

putChar:    strb    w0, [outBuf, x5] 
            add     x5, x5, #1 

            // If the exponent decrements down to 0, 
            // output a decimal point: 

            cmp     exponent, #0 
            bne     noDecimalPt 

            cmp     decDigits, #0 
            beq     noDecimalPt 

            mov     w0, #'.' 
            strb    w0, [outBuf, x5] 
            add     x5, x5, #1 

noDecimalPt: 
            sub     exponent, exponent, #1  // Count down to "." output. 
            add     x2, x2, #1    // # of digits thus far 
            subs    x3, x3, #1    // Total # of digits to output 
            bne     repeatUntilX3eq0 

// Zero-terminate string and leave: 

r64BufDone: strb    wzr, [outBuf, x5] 
❾ clcAndRet:  msr     nzcv, xzr    // clc = no error 
            b.al    popRet 

strOverflow: 
            mov     x0, #-3 // String overflow 
            b.al    ErrorExit 

valOutOfRange: 
            mov     x0, #-1 // Range error 
❿ ErrorExit:  mrs     x1, nzcv 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1        // stc = error 
            strb    wzr, [outBuf]   // Just to be safe 

            // Change X0 on return: 

            str     x0, [fp, #rts.x0x1] 

popRet: 
            ldp     x0, x1,   [fp, #rts.x0x1] 
            ldp     x2, x3,   [fp, #rts.x2x3] 
            ldp     x4, x5,   [fp, #rts.x4x5] 
            ldp     x19, x20, [fp, #rts.x19x20] 
            ldp     x21, x22, [fp, #rts.x21x22] 
            ldp     x23, x24, [fp, #rts.x23x24] 
            ldr     x25,      [fp, #rts.x25] 
            leave 
            endp    r64ToStr 

r64ToStr 函数将 D0 中的浮点值转换为标准十进制格式的字符串,支持输出字段宽度、小数点后的数字位数以及用于填充通常为空白的前导位置的字符。

在适当初始化后,r64ToStr 首先检查 NaN(非数值)、INF(无穷大)和 -INF(负无穷大) ❶;这些值需要特殊的非数值输出字符串,但仍然需要填充至 fWidth 字符数。r64ToStr 调用 FPDigits 将尾数转换为十进制数字字符的字符串(并以整数形式获得十的幂指数) ❷。接下来的步骤是根据小数点后出现的数字位数对数字进行四舍五入 ❸。该代码计算出由 FPDigits 生成的字符串中的索引位置,该位置在 decDigits 参数指定的数字位数之后。它获取该字符(该字符将是 '0' 到 '9')并将其 ASCII 码加 5。如果结果大于字符 '9' 的 ASCII 码,代码必须将字符串中的前一个数字加 1。当然,如果该字符是 '9',则会发生溢出,进位必须向前传递到前一个字符。如果进位一直传递到字符串的第一个字符,代码必须将所有字符向右移动一位,并在字符串的开头插入 '1' ❹。

接下来,代码输出与最终小数字符串相关的字符。算法分为两个部分 ❺,其中一部分处理正指数(包括 0),另一部分处理负指数。对于负指数,代码将输出任何填充字符、数字符号(仍保存在 X2 中)以及从尾数字符串转换得到的 decDigits 位数字 ❻。如果字段宽度和 decDigits 足够大,代码将简单地输出 '0' 字符,直到超过第 16 位有效数字。如果输出的数字位数超过调用者传递的字段宽度,widthTooBig 代码 ❼ 将输出 # 字符以指示格式错误(在浮点转换中的标准 HLL 格式错误处理方式)。

该代码处理大于或等于 1.0 的浮点数值转换(正指数) ❽。该代码输出必要的填充字符和数值的符号,然后计算输出字符串中小数点的位置,并按照之前描述的方式四舍五入字符串中的最后一个数字。然后,它输出由 FPDigits 返回的字符直到该位置。最后,输出小数点,后跟剩余的小数位。如果代码无法将数字适配到指定的字段宽度(以及小数位数),则会将控制转移到 widthTooBig 来生成错误字符串。

为了通知调用者可能发生的错误,该代码在返回时会清除进位标志 ❾(如果转换成功),或者如果发生错误,则在返回时设置进位标志 ❿。这允许调用者在调用 r64ToStr 后,通过简单的 bcs 或 bcc 指令来轻松测试成功/失败。

列表 9-13 中处理的最终输出格式为指数(科学)形式。这个转换由两个函数处理:expToBuf 和 e64ToStr。前者处理输出字符串中指数部分的格式化:

// Listing9-13.S (cont.) 
//
// expToBuf 
//
// Unsigned integer to buffer 
// Used to output up to three-digit exponents 
//
// Inputs: 
//
//  X0-   Unsigned integer to convert 
//  X1-   Exponent print width 1-3 
//  X2-   Points at buffer (must have at least 4 bytes) 
//
// Outputs: 
//
//  Buffer contains the string representing the converted 
//  exponent. 
//
//  Carry is clear on success, set on error. 

            proc    expToBuf 
            stp     x0, lr, [sp, #-16]! 
            stp     x1, x3, [sp, #-16]! 
            stp     x4, x5, [sp, #-16]! 

            mov     x5, xzr     // Initialize output string. 
            mov     x4, #10     // For division by 10 

// Verify exponent digit count is in the range 1-3: 

 ❶ cmp     x1, #1 
            blo     badExp 
            cmp     x1, #3 
            bhi     badExp 

// Verify the actual exponent will fit in the number of digits: 

          ❷ cmp     x1, #2 
            blo     oneDigit 
            beq     twoDigits 

            // Must be 3: 

            cmp     x0, #1000 
            bhs     badExp 

// Convert three-digit value to a string: 

          ❸ udiv    x1, x0, x4      // X1 = quotient 
            msub    x3, x1, x4, x0  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            udiv    x0, x1, x4      // X0 = quotient 
            msub    x3, x0, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            udiv    x1, x0, x4      // X1 = quotient 
            msub    x3, x1, x4, x0  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

            b.al    outputExp 

// Single digit is easy: 

oneDigit: 
          ❹ cmp     x0, #10 
            bhs     badExp 

            orr     x5, x0, #'0' 
            b.al    outputExp 

// Convert value in the range 10-99 to a string 
// containing two characters: 

twoDigits: 
          ❺ cmp     x0, #100 
            bhs     badExp 

            udiv    x1, x0, x4      // X1 = quotient 
            msub    x3, x1, x4, x0  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

 udiv    x0, x1, x4      // X0 = quotient 
            msub    x3, x0, x4, x1  // X3 = remainder 
            orr     x3, x3, #'0' 
            orr     x5, x3, x5, lsl #8 

// Store the string into the buffer (includes a 0 
// byte in the HO positions of W5): 

outputExp: 
          ❻ str     w5, [x2] 
            ldp     x4, x5, [sp], #16 
            ldp     x1, x3, [sp], #16 
            ldp     x0, lr, [sp], #16 
            msr     nzcv, xzr    // clc = no error 
            ret 
            leave 

badExp: 
            ldp     x4, x5, [sp], #16 
            ldp     x1, x3, [sp], #16 
            ldp     x0, lr, [sp], #16 
            mrs     x0, nzcv 
            orr     x0, x0, #(1 << 29) 
            msr     nzcv, x0        // stc = error 
            mov     x0, #-1         // Value out of range ... 
            ret 
            endp    expToBuf 

expToBuf 函数生成一个恰好由一、两或三个数字组成的字符串(具体根据调用者传递的 X0 和 X1 参数决定)。expToBuf 函数首先验证指数位数是否在范围内 ❶,并且实际的指数值是否能够适应指定的数字位数 ❷。如果指数输出为三位数(正常情况 ❸)、一位数 ❹ 或两位数 ❺,代码会跳转到三个不同的输出转换代码序列。代码将这些字符存储到 X2 指向的缓冲区中 ❻。

该函数通过进位标志返回错误状态,若操作成功,则进位标志清除;如果指数过大或转换后的数字无法适应 X1 指定的字符位置数,则进位标志被设置。除此之外,expToBuf 基本上是一个 switch 语句(使用 if...then...else 逻辑实现),它有三个情况:分别对应每种指数大小(一位、两位或三位)。

e64ToStr 函数处理从双精度到字符串的转换,采用指数格式:

// Listing9-13.S (cont.) 
//
// e64ToStr 
//
// Converts a REAL64 floating-point number to the 
// corresponding string of digits. Note that this 
// function always emits the string using scientific 
// notation; use the r64ToStr routine for decimal notation. 
//
// On entry: 
//
//  D0-     (e64) Double-precision value to convert 
//
//  X0-     (buffer) e64ToStr stores the resulting characters in 
//          this buffer. 
//
//  X1-     (width) Field width for the number (note that this 
//          is an *exact* field width, not a minimum 
//          field width) 
//
//  X2-     (fill) Padding character if the number is smaller 
//          than the specified field width 
//
//  X3-     (expDigs) Number of exponent digits (2 for real32 
//          and 3 for real64) 
//
//  X4-     (maxLength) Maximum buffer size 
//
// On exit: 
//
//  Buffer contains the newly formatted string. If the 
//  formatted value does not fit in the width specified, 
//  e64ToStr will store "#" characters into this string. 
//
//  Carry-  Clear if no error, set if error. 
//          If error, X0 is 
//              -3 if string overflow 
//              -2 if bad width 
//              -1 if value out of range 
//
//-----------------------------------------------------------
//
// Unlike the integer-to-string conversions, this routine 
// always right-justifies the number in the specified 
// string. Width must be a positive number; negative 
// values are illegal (actually, they are treated as 
// *really* big positive numbers that will always raise 
// a string overflow exception). 
//
//***********************************************************

            proc       e64ToStr 

#define     e2sWidth   x19      // chkNaNINF expects this here. 
#define     e2sExp     x20 
#define     e2sFill    x21      // chkNaNINF expects this here. 
#define     e2sBuffer  x22      // chkNaNINF expects this here. 
#define     e2sMaxLen  x23 
#define     e2sExpDigs x24 

#define     e2sSign    w25 
#define     eFailAdrs  x25      // chkNaNINF expects this here. 
#define     e2sMantSz  x26 

            locals  e2s 
            qword   e2s.x1x2 
            qword   e2s.x3x4 
            qword   e2s.x5x19 
            qword   e2s.x20x21 
            qword   e2s.x22x23 
            qword   e2s.x24x25 
            qword   e2s.x26x27 
            dword   e2s.x0 
            dword   e2s.d0 
            byte    e2s.digits, 64 
            byte    e2s.stack, 64 
            endl    e2s 

            // Build activation record and preserve registers: 

            enter   e2s.size 
            str     x0,       [fp, #e2s.x0] 
            stp     x1,  x2,  [fp, #e2s.x1x2] 
            stp     x3,  x4,  [fp, #e2s.x3x4] 
            stp     x5,  x19, [fp, #e2s.x5x19] 
            stp     x20, x21, [fp, #e2s.x20x21] 
            stp     x22, x23, [fp, #e2s.x22x23] 
            stp     x24, x25, [fp, #e2s.x24x25] 
            stp     x26, x27, [fp, #e2s.x26x27] 
            str     d0,       [fp, #e2s.d0] 

            // Move important data to nonvolatile registers: 

            mov     e2sBuffer, x0 
            mov     e2sWidth, x1 
            mov     e2sFill, x2 
            mov     e2sExpDigs, x3 
            mov     e2sMaxLen, x4 

            // See if the width is greater than the buffer size: 

            cmp     e2sWidth, e2sMaxLen 
            bhs     strOvfl 

            strb    wzr, [e2sBuffer, e2sWidth]  // Zero-terminate str. 

// First, make sure the width isn't 0: 

          ❶ cmp     e2sWidth, #0 
            beq     valOutOfRng 

// Just to be on the safe side, don't allow widths greater 
// than 1024: 

 cmp     e2sWidth, #1024 
            bhi     badWidth 

// Check for NaN and INF: 

          ❷ lea     failAdrs, exit_eToBuf   // Note: X25, used before 
            bl      chkNaNINF               // e2sSign (also X25) 

// Okay, do the conversion: 

          ❸ add     x0, fp, #e2s.digits // lea x1, e2s.digits 
            bl      FPDigits        // Convert D0 to digit str. 
            mov     e2sExp, x1      // Save away exponent result. 
            mov     e2sSign, w2     // Save mantissa sign char. 

// Verify that there is sufficient room for the mantissa's sign, 
// the decimal point, two mantissa digits, the "E",
// and the exponent's sign. Also add in the number of digits 
// required by the exponent (2 for single, 3 for double). 
//
// -1.2e+00    :real4 
// -1.2e+000   :real8 

          ❹ add     x2, e2sExpDigs, #6    // Minimum number of posns 
            cmp     x2, e2sWidth 
            bls     goodWidth 

// Output a sequence of "#...#" chars (to the specified width) 
// if the width value is not large enough to hold the 
// conversion: 

            mov     x2, e2sWidth 
            mov     x0, #'#' 
            mov     x1, e2sBuffer 
fillPnd:    strb    w0, [x1] 
            add     x1, x1, #1 
            subs    x2, x2, #1 
            bne     fillPnd 
            b.al    exit_eToBuf 

// Okay, the width is sufficient to hold the number; do the 
// conversion and output the string here: 

goodWidth: 
            // Compute the # of mantissa digits to display, 
            // not counting mantissa sign, decimal point, 
            // "E", and exponent sign: 

          ❺ sub     e2sMantSz, e2sWidth, e2sExpDigs 
            sub     e2sMantSz, e2sMantSz, #4 

            // Round the number to the specified number of 
            // print positions. (Note: since there are a 
            // maximum of 16 significant digits, don't 
 // bother with the rounding if the field width 
            // is greater than 16 digits.) 

            cmp     e2sMantSz, #maxDigits 
            bhs     noNeedToRound 

            // To round the value to the number of 
            // significant digits, go to the digit just 
            // beyond the last one you are considering (e2sMantSz 
            // currently contains the number of decimal 
            // positions) and add 5 to that digit. 
            // Propagate any overflow into the remaining 
            // digit positions. 

            add     x1, e2sMantSz, #1 
            add     x2, fp, #e2s.digits // lea x2, e2s.digits 
            ldrb    w0, [x2, x1]        // Get least sig digit + 1\. 
            add     w0, w0, #5          // Round (for example, +0.5). 
            cmp     w0, #'9' 
            bhi     whileDigGT9 
            b.al    noNeedToRound 

// Sneak this code in here, after a branch, so the 
// loop below doesn't get broken up. 

firstDigitInNumber: 

            // If you get to this point, you've hit the 
            // first digit in the number, so you have to 
            // shift all the characters down one position 
            // in the string of bytes and put a "1" in the 
            // first character position. 

            ldr     x0, [x2, #8] 
            str     x0, [x2, #9] 
            ldr     x0, [x2] 
            str     x0, [x2, #1] 

            mov     x0, #'1'        // Store '1' in 1st 
            strb    w0, [x2]        // digit position. 

            // Bump exponent by 1, as the shift did 
            // a divide by 10\. 

            add     e2sExp, e2sExp, #1 
            b.al    noNeedToRound 

// Subtract out overflow and add the carry into the previous 
// digit (unless you hit the first digit in the number): 

whileDigGT9: 
            sub     w0, w0, #10 
            strb    w0, [x2, x1] 
            subs    x1, x1, #1 
            bmi     firstDigitInNumber 

 // Add in carry to previous digit: 

            ldrb    w0, [x2, x1] 
            add     w0, w0, #1 
            strb    w0, [x2, x1] 
            cmp     w0, #'9'        // Overflow if char > '9' 
            bhi     whileDigGT9 

noNeedToRound: 
            add     x2, fp, #e2s.digits // lea x2, e2s.digits 

// Okay, emit the string at this point. This is pretty easy, 
// since all you really need to do is copy data from the 
// digits array and add an exponent (plus a few other simple chars). 

          ❻ mov     x1, #0      // Count output mantissa digits. 
            strb    e2sSign, [e2sBuffer], #1 

// Output the first character and a following decimal point 
// if there are more than two mantissa digits to output. 

            ldrb    w0, [x2] 
            strb    w0, [e2sBuffer], #1 
            add     x1, x1, #1 
            cmp     x1, e2sMantSz 
            beq     noDecPt 

            mov     w0, #'.' 
            strb    w0, [e2sBuffer], #1 

noDecPt: 

// Output any remaining mantissa digits here. 
// Note that if the caller requests the output of 
// more than 16 digits, this routine will output 0s 
// for the additional digits. 

            b.al    whileX2ltMantSizeTest 

whileX2ltMantSize: 

            mov     w0, #'0' 
            cmp     x1, #maxDigits 
            bhs     justPut0 

            ldrb    w0, [x2, x1] 

justPut0: 
            strb    w0, [e2sBuffer], #1 
            add     x1, x1, #1 

whileX2ltMantSizeTest: 

            cmp     x1, e2sMantSz 
            blo     whileX2ltMantSize 

// Output the exponent: 

          ❼ mov     w0, #'e' 
            strb    w0, [e2sBuffer], #1 
            mov     w0, #'+' 
            mov     w4, #'-' 
            neg     x5, e2sExp 

            cmp     e2sExp, #0 
            csel    w0, w0, w4, ge 
            csel    e2sExp, e2sExp, x5, ge 

            strb    w0, [e2sBuffer], #1 

            mov     x0, e2sExp 
            mov     x1, e2sExpDigs 
            mov     x2, e2sBuffer 
            bl      expToBuf 
            bcs     error 

exit_eToBuf: 
            msr     nzcv, xzr    // clc = no error 
            ldr     x0, [fp, #e2s.x0] 

returnE64: 
            ldp     x1,  x2,  [fp, #e2s.x1x2] 
            ldp     x3,  x4,  [fp, #e2s.x3x4] 
            ldp     x5,  x19, [fp, #e2s.x5x19] 
            ldp     x20, x21, [fp, #e2s.x20x21] 
            ldp     x22, x23, [fp, #e2s.x22x23] 
            ldp     x24, x25, [fp, #e2s.x24x25] 
            ldp     x26, x27, [fp, #e2s.x26x27] 
            ldr     d0,       [fp, #e2s.d0] 
            leave 

strOvfl:    mov     x0, #-3 
            b.al    error 

badWidth:   mov     x0, #-2 
            b.al    error 

valOutOfRng: 
            mov     x0, #-1 
error: 
            mrs     x1, nzcv 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1        // stc = error 
            b.al    returnE64 

            endp    e64ToStr 

将尾数转换为字符串与 r64ToStr 中的例程非常相似,尽管指数形式稍微简单一些,因为格式总是将小数点放置在第一个尾数数字后面。与 r64ToStr 一样,e64ToStr 通过检查输入参数是否有效来开始 ❶(如果发生错误,将返回并在 X0 中设置错误代码及进位标志)。在参数验证之后,代码检查是否为 NaN 或 INF ❷。然后,它调用 FPDigits 将尾数转换为数字字符串 ❸(保存在本地缓冲区中)。此调用还返回值的符号以及十进制整数指数。

在计算出十进制指数值后,e64ToStr 函数会检查转换后的值是否适合由 Width 输入参数指定的空间 ❹。如果转换后的数字过大,e64ToStr 会生成一串 # 字符以表示错误。

请注意,这种情况不被视为返回进位标志已设置的错误。如果调用者指定了不足的字段宽度,函数仍然能够成功生成字符串转换;不过该字符串可能会被填充为 # 字符。只有在 e64ToStr 无法生成输出字符串时,进位标志才会被设置为错误。

在验证字符串能够适应指定的字段宽度后,e64ToStr 函数将结果四舍五入到指定的小数位数❺。这个算法与 r64ToStr 使用的算法相同。接下来,代码输出尾数位数❻。同样,这与 r64ToStr 的工作方式类似,只不过小数点总是放在第一个数字后(不需要计算它的位置)。最后,代码输出 e 后跟指数的符号字符❼,然后调用 expToBuf 将指数转换为一位、两位或三位的字符序列(由调用者通过 X3 传递的 expDigs 参数指定)。

列表 9-13 中的其余代码提供了主程序用来显示数据的实用函数(r64Print 和 e64Print),以及演示如何使用本节中函数的 asmMain 过程:

// Listing9-13.S (cont.) 
//
            proc    r64Print 

            stp     x0, x1, [sp, #-16]! 
            stp     x2, x3, [sp, #-16]! 
            stp     x4, x5, [sp, #-16]! 
            stp     x6, x7, [sp, #-16]! 
            stp     x8, lr, [sp, #-16]! 
            sub     sp, sp, #64 

            lea     x0, fmtStr1 
            lea     x1, r64str_1 
            mstr    x1, [sp] 
            bl      printf 

            add     sp, sp, #64 
            ldp     x8, lr, [sp], #16 
            ldp     x6, x7, [sp], #16 
            ldp     x4, x5, [sp], #16 
            ldp     x2, x3, [sp], #16 
 ldp     x0, x1, [sp], #16 
            ret 
            endp    r64Print 

            proc    e64Print 
            stp     x0, x1, [sp, #-16]! 
            stp     x2, x3, [sp, #-16]! 
            stp     x4, x5, [sp, #-16]! 
            stp     x6, x7, [sp, #-16]! 
            stp     x8, lr, [sp, #-16]! 
            sub     sp, sp, #64 

            lea     x0, fmtStr3 
            lea     x1, r64str_1 
            mstr    x1, [sp] 
            bl      printf 

            add     sp, sp, #64 
            ldp     x8, lr, [sp], #16 
            ldp     x6, x7, [sp], #16 
            ldp     x4, x5, [sp], #16 
            ldp     x2, x3, [sp], #16 
            ldp     x0, x1, [sp], #16 
            ret 
            endp    e64Print 

请注意,这些函数保留了所有非易失性寄存器,因为 printf()可以修改它们。

asmMain 函数是一个典型的浮点数字符串转换函数示范程序。它使用不同的输入参数调用 r64ToStr 和 e64ToStr 函数,演示这些函数的用法:

// Listing9-13.S (cont.) 
//
❶ r64_1:      .double  1.234567890123456 
            .double  0.0000000000000001 
            .double  1234567890123456.0 
            .double  1234567890.123456 
            .double  99499999999999999.0 
            .dword   0x7ff0000000000000 
            .dword   0xfff0000000000000 
            .dword   0x7fffffffffffffff 
            .dword   0xffffffffffffffff 
            .double  0.0 
            .double  -0.0 
fCnt         =       (. - r64_1) 

rSizes:     .word    12, 12, 2, 7, 0, 0, 0, 0, 0, 2, 2 

e64_1:      .double  1.234567890123456e123 
            .double  1.234567890123456e-123 
e64_3:      .double  1.234567890123456e1 
 .double  1.234567890123456e-1 
            .double  1.234567890123456e10 
            .double  1.234567890123456e-10 
            .double  1.234567890123456e100 
            .double  1.234567890123456e-100 
            .dword   0x7ff0000000000000 
            .dword   0xfff0000000000000 
            .dword   0x7fffffffffffffff 
            .dword   0xffffffffffffffff 
            .double  0.0 
            .double  -0.0 
eCnt         =       (. - e64_1) 

eSizes:     .word    6, 9, 8, 12, 14, 16, 18, 20, 12, 12, 12, 12, 8, 8 
expSizes:   .word    3, 3, 2, 2, 2, 2, 3, 3, 2, 2, 2, 2, 2, 2 

// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            dword   am.x8x9 
            dword   am.x27 
            byte    am.stk, 64 
            endl    am 

            enter   am.size     // Activation record 
            stp     x8, x9, [fp, #am.x8x9] 
            str     x27,    [fp, #am.x27] 

// F output 

            mov     x2, #16         // decDigits 
fLoop: 
            ldr     d0, r64_1 
            lea     x0, r64str_1    // Buffer 
            mov     x1, #30         // fWidth 
            mov     x3, #'.'        // Fill 
            mov     x4, 32          // maxLength 
            bl      r64ToStr 
            bcs     fpError 
            bl      r64Print 
            subs    x2, x2, #1 
            bpl     fLoop 

            lea     x0, newlines 
            bl      printf 

            lea     x5, r64_1 
            lea     x6, rSizes 
            mov     x7, #fCnt/8 
f2Loop:     ldr     d0, [x5], #8 
            lea     x0, r64str_1    // Buffer 
            mov     x1, #30         // fWidth 
 ldr     w2, [x6], #4    // decDigits 
            mov     x3, #'.'        // Fill 
            mov     x4, #32         // maxLength 
            bl      r64ToStr 
            bcs     fpError 
            bl      r64Print 
            subs    x7, x7, #1 
            bne     f2Loop 

// E output 

            lea     x0, expStr 
            bl      printf 

            lea     x5, e64_1 
            lea     x6, eSizes 
            lea     x7, expSizes 
            mov     x8, #eCnt/8 
eLoop: 
            ldr     d0, [x5], #8 
            lea     x0, r64str_1    // Buffer 
            ldr     w1, [x6], #4    // fWidth 
            mov     x2, #'.'        // Fill 
            ldr     w3, [x7], #4    // expDigits 
            mov     x4, #32         // maxLength 
            bl      e64ToStr 
            bcs     fpError 
            bl      e64Print 
            subs    x8, x8, #1 
            bne     eLoop 
            b.al    allDone 

fpError: 
            mov     x1, x0 
            lea     x0, fmtStr2 
            mstr    x1, [sp] 
            bl      printf 

allDone: 
            ldp     x8, x9, [fp, #am.x8x9] 
            ldr     x27,    [fp, #am.x27] 
            leave 
            endp    asmMain 

列表 9-13 将浮点常量值放在代码段中,而不是只读数据段❶,这样在查看主程序时更容易修改它们。

以下是列表 9-13 的构建命令和示例输出:

% ./build Listing9-13 
% 1G 
Calling Listing9-13: 
r64ToStr: value='........... 1.2345678901234560' 
r64ToStr: value='............ 1.234567890123456' 
r64ToStr: value='............. 1.23456789012345' 
r64ToStr: value='.............. 1.2345678901234' 
r64ToStr: value='............... 1.234567890123' 
r64ToStr: value='................ 1.23456789012' 
r64ToStr: value='................. 1.2345678901' 
r64ToStr: value='.................. 1.234567890' 
r64ToStr: value='................... 1.23456789' 
r64ToStr: value='.................... 1.2345678' 
r64ToStr: value='..................... 1.234567' 
r64ToStr: value='...................... 1.23456' 
r64ToStr: value='....................... 1.2345' 
r64ToStr: value='........................ 1.234' 
r64ToStr: value='......................... 1.23' 
r64ToStr: value='.......................... 1.2' 
r64ToStr: value='............................ 1' 

r64ToStr: value='............... 1.234567890123' 
r64ToStr: value='............... 0.000000000000' 
r64ToStr: value='.......... 1234567890123456.00' 
r64ToStr: value='........... 1234567890.1234560' 
r64ToStr: value='............ 99500000000000000' 
r64ToStr: value='INF                           ' 
r64ToStr: value='-INF                          ' 
r64ToStr: value='NaN                           ' 
r64ToStr: value='NaN                           ' 
r64ToStr: value='......................... 0.00' 
r64ToStr: value='.........................-0.00' 

Testing e64ToStr: 

e64ToStr: value='######' 
e64ToStr: value=' 1.2e-123' 
e64ToStr: value=' 1.2e+01' 
e64ToStr: value=' 1.23456e-01' 
e64ToStr: value=' 1.2345678e+10' 
e64ToStr: value=' 1.234567890e-10' 
e64ToStr: value=' 1.2345678901e+100' 
e64ToStr: value=' 1.234567890123e-100' 
e64ToStr: value='INF         ' 
e64ToStr: value='-INF        ' 
e64ToStr: value='NaN         ' 
e64ToStr: value='NaN         ' 
e64ToStr: value=' 0.0e+00' 
e64ToStr: value='-0.0e+00' 
Listing9-13 terminated 

该输出演示了双精度浮点数输出。如果你想将一个单精度值转换为字符串,首先将单精度值转换为双精度值,然后使用这段代码将得到的双精度值转换为字符串。 ### 9.3 字符串与数值的转换

数值转换为字符串和字符串转换为数值的过程有两个基本区别。首先,数值到字符串的转换通常不会出错(前提是你分配了足够大的缓冲区,以防转换函数写入缓冲区末尾之外的数据)。而字符串到数值的转换则必须处理如非法字符和数值溢出等错误的实际可能性。

一个典型的数字输入操作包括从用户读取一个字符字符串,然后将这个字符字符串转换为内部数字表示。例如,在 C++ 中,像 cin >> i32; 这样的语句从用户读取一行文本,并将该行文本开头的数字序列转换为 32 位有符号整数(假设 i32 是一个 32 位整数对象)。cin >> i32; 语句会跳过字符串中可能出现在实际数字字符前面的某些字符,如前导空格。输入字符串还可能包含超出数字输入末尾的额外数据(例如,可能从同一输入行读取两个整数值),因此输入转换程序必须确定数字数据在输入流中的结束位置。

通常,C++ 通过查找一组 分隔符 字符来实现这一点。分隔符字符集可以是简单的任何非数字字符;或者该集合可能包括空白字符(空格、制表符等),以及可能的一些其他字符,如逗号(,)或其他标点符号。为了举例,本节代码假设任何前导空格或制表符字符(ASCII 代码 9)可能出现在第一个数字字符之前,并且转换会在遇到第一个非数字字符时停止。可能的错误情况如下:

  • 字符串开头完全没有数字字符(跳过任何空格或制表符后)。

  • 数字字符串的值可能太大,无法适应预定的数字大小(例如,64 位)。

调用者需要确定在函数调用返回后,数字字符串是否以无效字符结尾。

9.3.1 十进制字符串转整数

将包含十进制数字的字符串转换为数字的基本算法如下:

1.  将累加器变量初始化为 0。

2.  跳过字符串中的任何前导空格或制表符。

3.  获取空格/制表符后的第一个字符。

4.  如果字符不是数字字符,返回错误。如果字符是数字字符,继续执行第 5 步。

5.  将数字字符转换为数字值(使用 AND 0xf)。

6.  将累加器设置为 = (累加器 × 10) + 当前数字值。

7.  如果发生溢出,返回并报告错误。如果没有发生溢出,则继续到第 8 步。

8.  从字符串中获取下一个字符。

9.  如果字符是数字字符,回到第 5 步;否则,继续执行第 10 步。

10.  返回成功,累加器中包含转换后的值。

对于有符号整数输入,使用相同的算法,并做以下修改:

  • 如果第一个非空格/制表符字符是连字符(-),设置一个标志,表示数字是负数,并跳过 - 字符。如果第一个字符不是 -,则清除标志。

  • 在成功转换结束时,如果标志被设置,在返回之前需要对整数结果进行取反(必须检查取反操作是否溢出)。

列表 9-14 实现了转换算法;我再次将这个列表分成几个部分,以便更好地注释它。第一部分包含通常的格式字符串,以及主程序用来测试 strtou 和 strtoi 函数的各种示例字符串。

// Listing9-14.S 
//
// String-to-numeric conversion 

            #include    "aoaa.inc"

false       =           0 
true        =           1 
tab         =           9 

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-14"
fmtStr1:    .ascii      "strtou: String='%s'\n"
            .asciz      "    value=%llu\n"

fmtStr2:    .ascii      "Overflow: String='%s'\n"
            .asciz      "    value=%llx\n"

fmtStr3:    .ascii      "strtoi: String='%s'\n"
            .asciz      "    value=%lli\n"

unexError:  .asciz      "Unexpected error in program\n"

value1:     .asciz      "  1"
value2:     .asciz      "12 " 
value3:     .asciz      " 123 " 
value4:     .asciz      "1234"
value5:     .asciz      "1234567890123456789"
value6:     .asciz      "18446744073709551615"
OFvalue:    .asciz      "18446744073709551616"
OFvalue2:   .asciz      "999999999999999999999"

ivalue1:    .asciz      "  -1"
ivalue2:    .asciz      "-12 " 
ivalue3:    .asciz      " -123 " 
ivalue4:    .asciz      "-1234"
ivalue5:    .asciz      "-1234567890123456789"
ivalue6:    .asciz      "-18446744073709551615"
OFivalue:   .asciz      "18446744073709551616"
OFivalue2:  .asciz      "-18446744073709551616"

            .code 
            .extern     printf 

////////////////////////////////////////////////////////////////////
//
// Return program title to C++ program: 

            proc        getTitle, public 
            lea         x0, ttlStr 
            ret 
            endp        getTitle 

这个程序没有任何静态、可写的数据;所有变量数据都保存在寄存器或局部变量中。

以下代码是 strtou 函数,它将包含十进制数字的字符串转换为无符号整数:

// Listing9-14.S (cont.) 
//
////////////////////////////////////////////////////////////////////
//
// strtou 
//
// Converts string data to a 64-bit unsigned integer 
//
// Input: 
//
//   X1-    Pointer to buffer containing string to convert 
//
// Outputs: 
//
//   X0-    Contains converted string (if success), error code 
//          if an error occurs 
//
//   X1-    Points at first char beyond end of numeric string 
//          If error, X1's value is restored to original value. 
//          Caller can check character at [X1] after a 
//          successful result to see if the character following 
//          the numeric digits is a legal numeric delimiter. 
//
//   C-     (carry flag) Set if error occurs, clear if 
//          conversion was successful. On error, X0 will 
//          contain 0 (illegal initial character) or 
//          0ffffffffffffffffh (overflow). 

            proc    strtou 

            str     x5, [sp, #-16]! 
            stp     x3, x4, [sp, #-16]! 
            stp     x1, x2, [sp, #-16]! 

            mov     x3, xzr 
            mov     x0, xzr 
            mov     x4, #10     // Used to mul by 10 

            // The following loop skips over any whitespace (spaces and 
            // tabs) that appear at the beginning of the string: 

          ❶ sub     x1, x1, #1      // Incremented below 
skipWS:     ldrb    w2, [x1, #1]!   // Fetch next (first) char. 
            cmp     w2, #' ' 
            beq     skipWS 
            cmp     w2, #tab 
            beq     skipWS 

            // If you don't have a numeric digit at this 
            // point, return an error. 

          ❷ cmp     w2, #'0'  // Note: '0' < '1' < ... < '9' 
            blo     badNumber 
            cmp     w2, #'9' 
            bhi     badNumber 

// Okay, the first digit is good. Convert the string 
// of digits to numeric form. 
//
// Have to check for unsigned integer overflow here. 
// Unfortunately, madd does not set the carry or 
// overflow flag, so you have to use umulh to see if 
// overflow occurs after a multiplication and do 
// an explicit add (rather than madd) to add the 
// digit into the accumulator (X0). 

❸ convert:    umulh   x5, x0, x4      // Acc * 10 
            cmp     x5, xzr 
            bne     overflow 
            and     x2, x2, #0xf    // Char -> numeric in X2 
            mul     x0, x0, x4      // Can't use madd! 
            adds    x0, x0, x2      // Add in digit. 
            bcs     overflow 

 ❹ ldrb    w2, [x1, #1]!   // Get next char. 
            cmp     w2, #'0'        // Check for digit. 
            blo     endOfNum 
            cmp     w2, #'9' 
            bls     convert 

// If you get to this point, you've successfully converted 
// the string to numeric form. Return without restoring 
// the value in X1 (X1 points at end of digits). 

❺ endOfNum:   ldp     x3, x4, [sp], #16   // Really X1, X2 
            mov     x2, x4 
            ldp     x3, x4, [sp], #16 
            ldr     x5, [sp], #16 

            // Because the conversion was successful, this 
            // procedure leaves X1 pointing at the first 
            // character beyond the converted digits. 
            // Therefore, we don't restore X1 from the stack. 

            msr     nzcv, xzr    // clr c = no error 
            ret 

// badNumber- Drop down here if the first character in 
//            the string was not a valid digit. 

❻ badNumber:  mov     x0, xzr 
errorRet:   mrs     x1, nzcv    // Return error in carry flag. 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1    // Set c = error. 

            ldp     x1, x2, [sp], #16 
            ldp     x3, x4, [sp], #16 
            ldr     x5, [sp], #16 
            ret 

// overflow- Drop down here if the accumulator overflowed 
//           while adding in the current character. 

overflow:   mov     x0, #-1  // 0xFFFFFFFFFFFFFFFF 
            b.al    errorRet 
            endp    strtou 

进入 strtou 时,X1 寄存器指向待转换字符串的第一个字符。该函数首先跳过字符串中的任何空白字符(空格和制表符),使 X1 指向第一个非空白/非制表符字符❶。

在任何空白字符之后,第一个字符必须是十进制数字,否则 strtou 必须返回转换错误。因此,在找到非空白字符后,代码会检查该字符是否在'0'到'9'的范围内❷。

在验证第一个字符是数字之后,代码进入主要的转换循环❸。通常,你只需将字符转换为整数(与 0xF 进行按位与操作),然后将 X0 寄存器中的累加器乘以 10,并加上字符的值。这可以通过两条指令完成:

and  x2, x2, #0xf 
madd x0, x0, x4, x2 // X4 contains 10\. 

唯一的问题是,使用这两条指令不能检测溢出(strtou 函数必须执行这一操作)。为了检测因乘以 10 而导致的溢出,代码必须使用 umulh 指令,并检查结果是否为 0(如果不是 0,说明发生了溢出)❸。如果 umulh 的结果为 0,代码可以放心地将累加器(X0)乘以 10 而不必担心溢出。当然,在将字符的值加到 X0 和 10 的积上时,仍然可能发生溢出,因此你仍然不能使用 madd 指令;相反,你必须先将累加器乘以 10,然后使用 adds 指令将字符值加进去,并立即检查进位标志。

转换循环会重复这一过程,直到发生溢出或遇到非数字字符。一旦遇到非数字字符❹,转换后的整数值会保存在 X0 寄存器中,函数返回时,进位标志被清除。注意,如果转换成功,strtou 函数不会恢复 X1 寄存器的值;相反,它会返回时让 X1 指向第一个非数字字符❺。调用者有责任检查这个字符,看看它是否合法。

如果发生溢出或遇到非法的起始字符,函数会返回时设置进位标志,并在 X0 寄存器中放入错误代码❻。

以下代码是 strtoi 过程,它是 strtou 过程的有符号整数版本:

// Listing9-14.S (cont.) 
//
// strtoi 
//
// Converts string data to a 64-bit signed integer 
//
// Input: 
//
//   X1-    Pointer to buffer containing string to convert 
//
// Outputs: 
//
//   X0-    Contains converted string (if success), error code 
//          if an error occurs 
//
//   X1-    Points at first char beyond end of numeric string. 
//          If error, X1's value is restored to original value. 
//          Caller can check character at [X1] after a 
//          successful result to see if the character following 
//          the numeric digits is a legal numeric delimiter. 
//
//   C-    (carry flag) Set if error occurs, clear if 
//         conversion was successful. On error, X0 will 
//         contain 0 (illegal initial character) or 
//         -1 (overflow). 

tooBig:     .dword  0x7fffffffffffffff 

            proc    strtoi 

            locals  si 
            qword   si.saveX1X2 
            endl    si 

            enter   si.size 

            // Preserve X1 in case you have to restore it; 
            // X2 is the sign flag: 

            stp     x1, x2, [fp, #si.saveX1X2] 

            // Assume you have a nonnegative number: 

            mov     x2, #false 

// The following loop skips over any whitespace (spaces and 
// tabs) that appear at the beginning of the string: 

          ❶ sub     x1, x1, #1  // Adjust for +1 below. 
skipWSi:    ldrb    w0, [x1, #1]! 
            cmp     w0, #' ' 
            beq     skipWSi 
            cmp     w0, #tab 
            beq     skipWSi 

            // If the first character you've encountered is 
            // '-', then skip it, but remember that this is 
            // a negative number: 

          ❷ cmp     w0, #'-' 
            bne     notNeg 
            mov     w2, #true 
            add     x1, x1, #1  // Skip '-' 

❸ notNeg:     bl      strtou       // Convert string to integer. 
            bcs     hadError 

            // strtou returned success. Check the negative 
            // flag and negate the input if the flag 
            // contains true: 

          ❹ cmp     w2, #true 
            bne     itsPosOr0 

            negs    x0, x0 
            bvs     overflowi 
            ldr     x2, [fp, #si.saveX1X2+8] 
 msr     nzcv, xzr   // clr c = no error 
            leave 

// Success, so don't restore X1: 

itsPosOr0: 
            ldr     x2, tooBig 
            cmp     x0, x2     // Number is too big. 
            bhi     overflowi 
            ldr     x2, [fp, #si.saveX1X2+8] 
            msr     nzcv, xzr  // clr c = no error 
            leave 

// If you have an error, you need to restore RDI from the stack: 

overflowi:  mov     x0, #-1     // Indicate overflow. 
hadError: 
            mrs     x2, nzcv    // Return error in carry flag. 
            orr     x2, x2, #(1 << 29) 
            msr     nzcv, x2    // Set c = error. 
            ldp     x1, x2, [fp, #si.saveX1X2] 
            leave 
            endp    strtoi 

strtoi 函数将包含有符号整数的字符串转换为 X0 中的相应值。代码首先消除空白❶,然后检查是否存在'-'字符❷。该函数在 X2 寄存器中维护一个“负数标志”(0 = 非负数,1 = 负数)。跳过可选的符号字符后,代码调用 strtou 函数将后续的字符串转换为无符号值❸。

从 strtou 返回后,strtoi 函数检查 X2 中的符号标志,如果应该是负数,则对数字进行取反❹。无论是负数还是非负数,代码还会检查是否发生溢出,并在发生溢出时返回错误。

与 strtou 一样,strtoi 函数在转换成功时不会恢复 X1。然而,如果发生溢出或 strtou 报告错误,它将恢复 X1。

当你调用 strtou 将字符串转换为整数时,strtoi 允许在表示负数的字符串中的减号和第一个数字之间有任意数量的空格。如果这对你来说是个问题,可以修改 strtou 来跳过空格,然后调用一个从属例程进行转换;接着,让 strtoi 调用该从属例程(如果合适的话,它会返回非法初始字符错误),而不是直接使用 strtou。

asmMain 函数演示了调用 strtou 和 strtoi 函数:

// Listing9-14.S (cont.) 
//
////////////////////////////////////////////////////////////////////
//
// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            byte    am.shadow, 64 
            endl    am 

            enter   am.size 

// Test unsigned conversions: 

            lea     x1, value1 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value1 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, value2 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, value3 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value3 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, value4 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value4 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

 lea     x1, value5 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value5 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, value6 
            bl      strtou 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr1 
            lea     x1, value6 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, OFvalue 
            bl      strtou 
            bcc     UnexpectedError 
            cmp     x0, xzr        // Nonzero for overflow 
            beq     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr2 
            lea     x1, OFvalue 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, OFvalue2 
            bl      strtou 
            bcc     UnexpectedError 
            cmp     x0, xzr        // Nonzero for overflow 
            beq     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr2 
            lea     x1, OFvalue2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

// Test signed conversions: 

            lea     x1, ivalue1 
            bl      strtoi 
            bcs     UnexpectedError 

            mov     x2, x0 
 lea     x0, fmtStr3 
            lea     x1, ivalue1 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, ivalue2 
            bl      strtoi 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr3 
            lea     x1, ivalue2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, ivalue3 
            bl      strtoi 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr3 
            lea     x1, ivalue3 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, ivalue4 
            bl      strtoi 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr3 
            lea     x1, ivalue4 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, ivalue5 
            bl      strtoi 
            bcs     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr3 
            lea     x1, ivalue5 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, ivalue6 
            bl      strtoi 
            bcs     UnexpectedError 

 mov     x2, x0 
            lea     x0, fmtStr3 
            lea     x1, ivalue6 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, OFivalue 
            bl      strtoi 
            bcc     UnexpectedError 
            cmp     x0, xzr        // Nonzero for overflow 
            beq     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr2 
            lea     x1, OFivalue 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            lea     x1, OFivalue2 
            bl      strtoi 
            bcc     UnexpectedError 
            cmp     x0, xzr        // Nonzero for overflow 
            beq     UnexpectedError 

            mov     x2, x0 
            lea     x0, fmtStr2 
            lea     x1, OFivalue2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            b.al    allDone 

UnexpectedError: 
            lea     x0, unexError 
            bl      printf 

allDone:    leave   // Returns to caller 
            endp    asmMain 

Listing 9-14 中的 asmMain 函数是一个典型的测试程序;它将只读数据段中出现的各种字符串转换为相应的整数值并显示出来。它还测试了几个溢出条件,以验证例程是否正确处理溢出。

以下是 Listing 9-14 中程序的构建命令和示例输出:

% ./build Listing9-14 
% ./Listing9-14 
Calling Listing9-14: 
strtou: String='  1' 
    value=1 
strtou: String='12 ' 
    value=12 
strtou: String=' 123 ' 
    value=123 
strtou: String='1234' 
    value=1234 
strtou: String='1234567890123456789' 
    value=1234567890123456789 
strtou: String='18446744073709551615' 
    value=18446744073709551615 
Overflow: String='18446744073709551616' 
    value=ffffffffffffffff 
Overflow: String='999999999999999999999' 
    value=ffffffffffffffff 
strtoi: String='  -1' 
    value=-1 
strtoi: String='-12 ' 
    value=-12 
strtoi: String=' -123 ' 
    value=-123 
strtoi: String='-1234' 
    value=-1234 
strtoi: String='-1234567890123456789' 
    value=-1234567890123456789 
strtoi: String='-18446744073709551615' 
    value=1 
Overflow: String='18446744073709551616' 
    value=ffffffffffffffff 
Overflow: String='-18446744073709551616' 
    value=ffffffffffffffff 
Listing9-14 terminated 

对于扩展精度的字符串到数值转换,只需修改 strtou 函数,加入扩展精度累加器,然后进行扩展精度的乘法运算(而不是标准乘法)。

9.3.2 十六进制字符串转换为数值形式

与数值输出类似,十六进制输入是最简单的数值输入例程。将十六进制字符串转换为数值形式的基本算法如下:

1.  将累加器值初始化为 0。

对于每个有效的十六进制数字字符,重复步骤 3 到步骤 6;如果字符不是有效的十六进制数字,则跳到步骤 7。

3.  将十六进制字符转换为 0 到 15 的值(0h 到 0Fh)。

4.  如果累加器值的高 4 位不为零,则抛出异常。

5.  将当前值乘以 16(即左移 4 位)。

6.  将转换后的十六进制数字值添加到累加器中。

7.  检查当前输入字符,确保它是有效的分隔符。如果不是,抛出异常。

Listing 9-15 实现了这个针对 64 位值的十六进制输入例程。

// Listing9-15.S 
//
// Hexadecimal-string-to-numeric conversion 

            #include    "aoaa.inc"

false       =           0 
true        =           1 
tab         =           9 

            .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-15"
fmtStr1:    .ascii      "strtoh: String='%s' " 
            .asciz      "value=%llx\n"

fmtStr2:    .asciz      "Error, str='%s', x0=%lld\n"

fmtStr3:    .ascii      "Error, expected overflow: x0=%llx, " 
            .asciz      "str='%s'\n"

fmtStr4:    .ascii      "Error, expected bad char: x0=%llx, " 
            .asciz      "str='%s'\n"

hexStr:     .asciz      "1234567890abcdef"
hexStrOVFL: .asciz      "1234567890abcdef0"
hexStrBAD:  .asciz      "x123"

            .code 
            .extern     printf 

/////////////////////////////////////////////////////////////
//
// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

/////////////////////////////////////////////////////////////
//
// strtoh: 
//
// Converts string data to a 64-bit unsigned integer 
//
// Input: 
//
//   X1-    Pointer to buffer containing string to convert 
//
// Outputs: 
//
//   X0-    Contains converted string (if success), error code 
//          if an error occurs 
//
//   X1-    Points at first char beyond end of hexadecimal string. 
//          If error, X1's value is restored to original value. 
//          Caller can check character at [X1] after a 
//          successful result to see if the character following 
//          the hexadecimal digits is a legal delimiter. 
//
//   C-     (carry flag) Set if error occurs, clear if 
//          conversion was successful. On error, X0 will 
//          contain 0 (illegal initial character) or 
//          -1 = 0xffffffffffffffff (overflow). 

            proc    strtoh 

            stp     x3, x4, [sp, #-16]! 
            stp     x1, x2, [sp, #-16]! 

            // This code will use the value in X3 to test 
            // whether overflow will occur in X0 when 
            // shifting to the left 4 bits: 

            mov     x3, 0xF000000000000000 
            mov     x0, xzr // Zero out accumulator. 

            // 0x5f is used to convert lowercase to 
            // uppercase: 

            mov     x4, 0x5f 

// The following loop skips over any whitespace (spaces and 
// tabs) that appear at the beginning of the string: 

            sub     x1, x1, #1  // Because of inc below 
skipWS:     ldrb    w2, [x1, #1]! 
            cmp     w2, #' ' 
            beq     skipWS 
            cmp     w2, #tab 
            beq     skipWS 

            // If you don't have a hexadecimal digit at this 
            // point, return an error: 

 ❶ cmp     w2, #'0'    // Note: '0' < '1' < ... < '9' 
            blo     badNumber 
            cmp     w2, #'9' 
            bls     convert 
            and     x2, x2, x4  // Cheesy LC -> UC conversion 
            cmp     w2, #'A' 
            blo     badNumber 
            cmp     w2, #'F' 
            bhi     badNumber 
            sub     w2, w2, #7  // Maps 41h..46h -> 3ah..3fh 

            // Okay, the first digit is good. Convert the 
            // string of digits to numeric form: 

❷ convert:    ands    xzr, x3, x0  // See if adding in the current 
            bne     overflow     // digit will cause an overflow. 

            and     x2, x2, #0xf // Convert to numeric in X2\. 

            // Multiply 64-bit accumulator by 16 and add in 
            // new digit: 

          ❸ lsl     x0, x0, #4 
            add     x0, x0, x2  // Never overflows 

            // Move on to next character: 

            ldrb    w2, [x1, #1]! 
            cmp     w2, #'0' 
            blo     endOfNum 
            cmp     w2, #'9' 
            bls     convert 

            and     x2, x2, x4  // Cheesy LC -> UC conversion 
            cmp     x2, #'A' 
            blo     endOfNum 
            cmp     x2, #'F' 
            bhi     endOfNum 
            sub     x2, x2, #7  // Maps 41h..46h -> 3ah..3fh 
            b.al    convert 

// If you get to this point, you've successfully converted 
// the string to numeric form: 

endOfNum: 

            // Because the conversion was successful, this 
            // procedure leaves X1 pointing at the first 
            // character beyond the converted digits. 
            // Therefore, don't restore X1 from the stack. 

            ldp     x3, x2, [sp], #16   // X3 holds old X1 
            ldp     x3, x4, [sp], #16 
            msr     nzcv, xzr   // clr c = no error 
            ret 

// badNumber- Drop down here if the first character in 
//            the string was not a valid digit. 

badNumber:  mov     x0, xzr 
            b.al    errorExit 

overflow:   mov     x0, #-1     // Return -1 as error on overflow. 
errorExit: 
            mrs     x1, nzcv    // Return error in carry flag. 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1    // Set c = error. 

            ldp     x1, x2, [sp], #16 
            ldp     x3, x4, [sp], #16 
            ret 
            endp    strtoh 

/////////////////////////////////////////////////////////////
//
// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            byte    am.stack, 64 
            endl    am 

            enter   am.size 

            // Test hexadecimal conversion: 

            lea     x1, hexStr 
            bl      strtoh 
            bcs     error 

            mov     x2, x0 
            lea     x1, hexStr 
            lea     x0, fmtStr1 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

 // Test overflow conversion: 

            lea     x1, hexStrOVFL 
            bl      strtoh 
            bcc     unexpected 

            mov     x2, x0 
            lea     x0, fmtStr2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

// Test bad character: 

            lea     x1, hexStrBAD 
            bl      strtoh 
            bcc     unexp2 

            mov     x2, x0 
            lea     x0, fmtStr2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

            b.al    allDone 

unexpected: mov     x3, x0 
            lea     x0, fmtStr3 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            mstr    x3, [sp, #16] 
            bl      printf 
            b.al    allDone 

unexp2:     mov     x3, x0 
            lea     x0, fmtStr4 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            mstr    x3, [sp, #16] 
            bl      printf 
            b.al    allDone 

error:      mov     x2, x0 
            lea     x0, fmtStr2 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            bl      printf 

allDone:    leave 
            endp    asmMain 

strtoh 函数类似于 strtou,不同之处在于它测试十六进制数字❶(而不仅仅是十进制数字),测试 HO 的 4 位以确定是否发生溢出❷(比十进制情况要简单得多),并且乘以十六进制基数(16)而不是 10❸。

以下是清单 9-15 中程序的构建命令和示例输出:

% ./build Listing9-15 
% ./Listing9-15 
Calling Listing9-15: 
strtoh: String='1234567890abcdef' value=1234567890abcdef 
Error, str='1234567890abcdef0', x0=-1 
Error, str='x123', x0 = 0 
Listing9-15 terminated 

对于处理大于 64 位的数字的十六进制字符串转换,你必须使用扩展精度的向左移 4 位。清单 9-16 演示了对 strtoh 函数进行必要的修改以进行 128 位转换。

// Listing9-16.S 
//
// 128-bit Hexadecimal-string-to-numeric conversion 

            #include    "aoaa.inc"

false       =           0 
true        =           1 
tab         =           9 

            .section    .rodata, "" 
 tlStr:     .asciz      "Listing 9-16"

fmtStr1:    .asciz      "strtoh128: value=%llx%llx, String='%s'\n"

hexStr:     .asciz      "1234567890abcdeffedcba0987654321"

            .code 
            .extern     printf 

/////////////////////////////////////////////////////////////
//
// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

/////////////////////////////////////////////////////////////
//
// strtoh128 
//
// Converts string data to a 128-bit unsigned integer 
//
// Input: 
//
//   X2-    Pointer to buffer containing string to convert 
//
// Outputs: 
//
//   X1:X0- Contains converted string (if success), error code 
//          if an error occurs 
//
//   X2-    Points at first char beyond end of hexadecimal 
//          string. If error, X2's value is restored to 
//          original value. 
//          Caller can check character at [X2] after a 
//          successful result to see if the character following 
//          the hexadecimal digits is a legal delimiter. 
//
//   C-     (carry flag) Set if error occurs, clear if 
//          conversion was successful. On error, X0 will 
//          contain 0 (illegal initial character) or 
//          -1 = 0xffffffffffffffff (overflow). 

            proc    strtoh128 

            stp     x4, x5, [sp, #-16]! 
            stp     x2, x3, [sp, #-16]! 

            // This code will use the value in X4 to test 
            // whether overflow will occur in X1 when 
            // shifting to the left 4 bits: 

            mov     x4, 0xF000000000000000 
            mov     x0, xzr // Zero out LO accumulator. 
            mov     x1, xzr // Zero out HO accumulator. 

            // 0x5f is used to convert lowercase to 
            // uppercase: 

            mov     x5, 0x5f 

// The following loop skips over any whitespace (spaces and 
// tabs) that appear at the beginning of the string: 

            sub     x2, x2, #1 // Because of inc below 
skipWS:     ldrb    w3, [x2, #1]! 
            cmp     w3, #' ' 
            beq     skipWS 
            cmp     w3, #tab 
            beq     skipWS 

            // If you don't have a hexadecimal digit at this 
            // point, return an error: 

            cmp     w3, #'0'   // Note: '0' < '1' < ... < '9' 
            blo     badNumber 
            cmp     w3, #'9' 
            bls     convert 
            and     x3, x3, x5 // Cheesy LC -> UC conversion 
            cmp     w3, #'A' 
            blo     badNumber 
            cmp     w3, #'F' 
            bhi     badNumber 
            sub     w3, w3, #7 // Maps 41h..46h -> 3ah..3fh 

            // Okay, the first digit is good. Convert the 
            // string of digits to numeric form: 

convert:    ands    xzr, x4, x1  // See whether adding in the current 
            bne     overflow     // digit will cause an overflow. 

 and     x3, x3, #0xf // Convert to numeric in X3\. 

            // Multiply 128-bit accumulator by 16 and add in 
            // new digit (128-bit extended-precision shift 
            // by 4 bits): 

          ❶ lsl     x1, x1, #4  // 128 bits shifted left 4 bits 
            orr     x1, x1, x0, lsr #60 
            lsl     x0, x0, #4 
            add     x0, x0, x3  // Never overflows 

            // Move on to next character: 

            ldrb    w3, [x2, #1]! 
            cmp     w3, #'0' 
            blo     endOfNum 
            cmp     w3, #'9' 
            bls     convert 

            and     x3, x3, x5  // Cheesy LC -> UC conversion 
            cmp     x3, #'A' 
            blo     endOfNum 
            cmp     x3, #'F' 
            bhi     endOfNum 
            sub     x3, x3, #7 // Maps 41h..46h -> 3ah..3fh 
            b.al    convert 

// If you get to this point, you've successfully converted 
// the string to numeric form: 

endOfNum: 

            // Because the conversion was successful, this 
            // procedure leaves X2 pointing at the first 
            // character beyond the converted digits.
            // Therefore, we don't restore X2 from the stack. 

            ldp     x4, x3, [sp], #16   // X4 holds old X2\. 
            ldp     x4, x5, [sp], #16 
            msr     nzcv, xzr   // clr c = no error 

            ret 

// badNumber- Drop down here if the first character in 
//            the string was not a valid digit. 

badNumber:  mov     x0, xzr 
            b.al    errorExit 

overflow:   mov     x0, #-1     // Return -1 as error on overflow. 
errorExit: 
 mrs     x1, nzcv    // Return error in carry flag. 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1    // Set c = error. 
            ldp     x2, x3, [sp], #16 
            ldp     x4, x5, [sp], #16 
            ret 
            endp    strtoh128 

/////////////////////////////////////////////////////////////
//
// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            byte    am.stack, 64 
            endl    am 

            enter   am.size 

// Test hexadecimal conversion: 

            lea     x2, hexStr 
            bl      strtoh128 

            lea     x3, hexStr 
            mov     x2, x0 
            lea     x0, fmtStr1 
            mstr    x1, [sp] 
            mstr    x2, [sp, #8] 
            mstr    x3, [sp, #16] 
            bl      printf 

allDone:    leave 
            endp    asmMain 

这段代码的工作方式类似于清单 9-15 中的代码。主要区别在于清单 9-16 中的 128 位向左移 4 位❶。该代码将 X0 向右移 60 位,然后将其向左移 4 位后进行 OR 操作,将 X0 的 4 位移入 X1。

以下是清单 9-16 的构建命令和示例输出:

% ./build Listing9-16 
% ./Listing9-16 
Calling Listing9-16: 
strtoh128: value=1234567890abcdeffedcba0987654321, String='1234567890abcdeffedcba0987654321' 
Listing9-16 terminated 

十六进制字符串到数字的函数按预期工作。

9.3.3 字符串转浮点数

将表示浮点数的字符字符串转换为 64 位双精度格式比本章前面出现的双精度转字符串转换稍微简单一些。因为十进制转换(没有指数)是更通用的科学计数法转换的一个子集,如果你能处理科学计数法,就可以轻松处理十进制转换。除此之外,基本算法是将尾数字符转换为整数形式以进行浮点数转换,然后读取(可选的)指数并相应调整双精度指数。转换的算法如下:

1.  首先去除任何前导空格或制表符字符(以及其他分隔符)。

2.  检查是否有前导加号(+)或减号(-)字符。如果有,跳过它。如果数字是负数,则将符号标志设置为 true(非负数则设置为 false)。

3.  将指数值初始化为 –16。该算法将根据字符串中的尾数数字创建一个整数值。由于双精度浮点数支持最多 16 位有效数字,因此将指数初始化为 –16 是考虑到了这一点。

4.  初始化一个有效数字计数器变量,记录到目前为止已处理的有效数字数量,初始值为 16。

5.  如果数字以任何前导零开头,则跳过它们(不要更改小数点左侧前导零的指数或有效数字计数)。

6.  如果扫描在处理完任何前导零后遇到小数点,则转到步骤 11;否则,继续执行步骤 7。

对于小数点左侧的每个非零数字,如果有效数字计数器不为 0,将整数累加器乘以 10 并加上该数字的数值。这是标准的整数转换。(如果有效数字计数器为 0,算法已经处理了 16 个有效数字,将忽略任何额外的数字,因为双精度格式无法表示超过 16 个有效数字的数值。)

对于小数点左侧的每个数字,将指数值(最初初始化为-16)加 1。

如果有效数字计数器不为 0,则递减有效数字计数器(这也会提供数字字符串数组的索引)。

如果遇到的第一个非数字字符不是小数点,则跳至步骤 14。

跳过小数点字符。

对于遇到的小数点右侧的每个数字,只要有效数字计数器不为 0,就继续将数字加到整数累加器中。如果有效数字计数器大于 0,则递减它。同时递减指数值。

如果到此为止算法没有遇到至少一个小数位数字,则报告非法字符异常并返回。

如果当前字符不是 e 或 E,则转到步骤 20。否则,跳过 e 或 E 字符并继续执行步骤 15。(注意,某些字符串格式也允许 d 或 D 表示双精度值。你也可以选择允许此格式,并在算法遇到 e 或 E 与 d 或 D 时检查值的范围。)

如果下一个字符是+或-,跳过它。如果符号字符是-,则将标志设置为 true;否则设置为 false(注意,这个指数符号标志不同于算法早期设置的尾数符号标志)。

如果下一个字符不是小数数字,则报告错误。

将从当前小数数字字符开始的数字字符串转换为整数。

将转换后的整数加到初始化为-16 的指数值上。

如果指数值超出范围–324 到+308,则报告超出范围的异常。

将当前为整数的尾数转换为浮点值。

取指数的绝对值,保留指数符号。该值将是 9 位或更少。

如果指数为正,则对于指数中每个设置的位,按该位位置所指定的幂次将当前尾数值乘以 10。例如,如果位 4、2 和 1 被设置,则将尾数值分别乘以 10¹⁶、10⁴和 10²。

如果指数为负,则对于指数中每个设置的位,按该位位置所指定的幂次将当前尾数值除以 10。例如,如果位 4、3 和 2 被设置,则将尾数值分别除以 10¹⁶、10⁸和 10⁴(从较大的值开始,逐步减少)。

24.  如果尾数是负数(在算法开始时设置了第一个符号标志),则将浮点数取反。

列表 9-17 提供了该算法的实现,并逐节进行了解释。第一部分对于本书中的示例程序来说是典型的,包含了一些常量声明、静态数据和 getTitle 函数。

// Listing9-17.S 
//
// Real string to floating-point conversion 

            #include    "aoaa.inc"

false       =           0 
true        =           1 
tab         =           9 

 .section    .rodata, "" 
ttlStr:     .asciz      "Listing 9-17"
fmtStr1:    .asciz      "strToR64: str='%s', value=%e\n"
errFmtStr:  .asciz      "strToR64 error, code=%ld\n"

❶ fStr1a:     .asciz      " 1.234e56"
fStr1b:     .asciz      "\t-1.234e+56"
fStr1c:     .asciz      "1.234e-56"
fStr1d:     .asciz      "-1.234e-56"
fStr2a:     .asciz      "1.23"
fStr2b:     .asciz      "-1.23"
fStr2c:     .asciz      "001.23"
fStr2d:     .asciz      "-001.23"
fStr3a:     .asciz      "1"
fStr3b:     .asciz      "-1"
fStr4a:     .asciz      "0.1"
fStr4b:     .asciz      "-0.1"
fStr4c:     .asciz      "0000000.1"
fStr4d:     .asciz      "-0000000.1"
fStr4e:     .asciz      "0.1000000"
fStr4f:     .asciz      "-0.1000000"
fStr4g:     .asciz      "0.0000001"
fStr4h:     .asciz      "-0.0000001"
fStr4i:     .asciz      ".1"
fStr4j:     .asciz      "-.1"
fStr5a:     .asciz      "123456"
fStr5b:     .asciz      "12345678901234567890"
fStr5c:     .asciz      "0"
fStr5d:     .asciz      "1." 
fStr6a:     .asciz      "0.000000000000000000001"

          ❷ .align      3 
values:     .dword      fStr1a, fStr1b, fStr1c, fStr1d 
            .dword      fStr2a, fStr2b, fStr2c, fStr2d 
            .dword      fStr3a, fStr3b 
            .dword      fStr4a, fStr4b, fStr4c, fStr4d 
            .dword      fStr4e, fStr4f, fStr4g, fStr4h 
            .dword      fStr4i, fStr4j 
            .dword      fStr5a, fStr5b, fStr5c, fStr5d 
            .dword      fStr6a 
            .dword      0 

❸ PotTbl:     .double     1.0e+256 
            .double     1.0e+128 
            .double     1.0e+64 
            .double     1.0e+32 
            .double     1.0e+16 
            .double     1.0e+8 
            .double     1.0e+4 
            .double     1.0e+2 
            .double     1.0e+1 
            .double     1.0e+0 

            .data 
r8Val:      .double     0.0 

 .code 
            .extern     printf 

///////////////////////////////////////////////////////////
//
// Return program title to C++ program: 

            proc    getTitle, public 
            lea     x0, ttlStr 
            ret 
            endp    getTitle 

只读部分包含了该程序将转换为浮点值的各种测试字符串❶。这些测试字符串经过精心挑选,以测试 strToR64 函数中大多数(成功的)路径。为了减少主程序的大小,列表 9-17 在一个循环中处理这些字符串。指针数组❷指向每个测试字符串,NULL 指针(0)标记列表的末尾。主程序将循环遍历这些指针来测试输入字符串。

PotTbl(10 的幂表)数组❸包含了各种 10 的幂。strToR64 函数使用这个表来将十进制指数(整数格式)转换为适当的 10 的幂:

// Listing9-17.S (cont.) 
//
// strToR64 
//
// On entry: 
//
//  X0- Points at a string of characters that represent a 
//      floating-point value 
//
// On return: 
//
//  D0- Converted result 
//  X0- On return, X0 points at the first character this 
//      routine couldn't convert (if no error). 
//
//  C-  Carry flag is clear if no error, set if error. 
//      X7 is preserved if an error, X1 contains an 
//      error code if an error occurs (else X1 is 
//      preserved). 

            proc    strToR64 

            locals  sr 
            qword   sr.x1x2 
            qword   sr.x3x4 
            qword   sr.x5x6 
            qword   sr.x7x0 
            dword   sr.d1 
            byte    sr.stack, 64    // Not really needed, but ... 
 endl    sr 

            enter   sr.size 

// Defines to give registers more 
// meaningful names: 

❶ #define mant    x1      // Mantissa value 
#define sigDig  x2      // Mantissa significant digits 
#define expAcc  x2      // Exponent accumulator 
#define sign    w3      // Mantissa sign 
#define fpExp   x4      // Exponent 
#define expSign w5      // Exponent sign 
#define ch      w6      // Current character 
#define xch     x6      // Current character (64 bits) 
#define ten     x7      // The value 10 

            // Preserve the registers this 
            // code modifies: 

          ❷ stp     x1, x2, [fp, #sr.x1x2] 
            stp     x3, x4, [fp, #sr.x3x4] 
            stp     x5, x6, [fp, #sr.x5x6] 
            stp     x7, x0, [fp, #sr.x7x0] 
            str     d1,     [fp, #sr.d1  ] 

            // Useful initialization: 

            mov     fpExp, xzr      // X3 Decimal exponent value 
            mov     mant, xzr       // X0 Mantissa value   
            mov     sign, wzr       // W2 Assume nonnegative. 

            // Initialize sigDig with 16, the number of 
            // significant digits left to process. 

            mov     sigDig, #16     // X1 

            // Verify that X0 is not NULL. 

            cmp     x0, xzr 
            beq     refNULL 

strToR64 函数使用#define 语句❶为它在各种寄存器中维护的局部变量创建了有意义的、可读性更强的名称。

尽管这个函数仅使用了寄存器 X0 到 X7 和 D1(它们在 ARM ABI 中都是易失的),但该函数会保存它修改过的所有寄存器❷。在汇编语言中,保持修改过的寄存器是良好的编程风格。这段代码没有保存 X0(假设转换成功),因为它返回 X0,指向已成功转换的字符串的末尾,作为函数结果。请注意,这段代码在 D0 中返回主函数结果。

在函数初始化之后,strToR64 函数通过跳过字符串开头的所有空白字符(空格和制表符)开始:

// Listing9-17.S (cont.) 

            sub     x0, x0, #1      // Will inc'd in loop 
whileWSLoop: 
            ldrb    ch, [x0, #1]!   // W5 
            cmp     ch, #' ' 
            beq     whileWSLoop 
            cmp     ch, #tab 
            beq     whileWSLoop 

这段代码退出时,ch(W6)包含第一个非空白字符,X0 指向该字符在内存中的位置。

在任何空白字符后面,字符串可能会可选地包含一个+或-字符。如果存在这些字符,代码会跳过它们,并且如果是'-'字符,设置尾数符号标志(sign)为 1:

// Listing9-17.S (cont.) 

            // Check for + or - 

            cmp     ch, #'+' 
            beq     skipSign 

            cmp     ch, #'-' 
            cinc    sign, sign, eq  // W2 
            bne     noSign 

skipSign:   ldrb    ch, [x0, #1]!   // Skip '-' 
noSign: 

紧接着一个符号字符(或者如果没有可选的符号字符)之后,字符串必须包含一个十进制数字字符或一个小数点。代码测试这两种条件之一,如果条件失败,则报告转换错误:

// Listing9-17.S (cont.) 

          ❶ sub     ch, ch, #'0'    // Quick test for '0' to '9' 
            cmp     ch, #9 
            bls     scanDigits      // Branch if '0' to '9' 

          ❷ cmp     ch, #'.'-'0'    // Check for '.' 
            bne     convError 

            // If the first character is a decimal point, 
            // the second character needs to be a 
            // decimal digit. 

          ❸ ldrb    ch, [x0, #1]!   // W5 Skip period. 
            cmp     ch, #'0' 
            blo     convError 
 cmp     ch, #'9' 
            bhi     convError 
            b.al    whileDigit2 

这段代码使用了一种常见的技巧来检查字符是否在'0'到'9'的范围内。它通过从字符❶的 ASCII 码中减去'0'的 ASCII 码来实现。如果字符在'0'到'9'的范围内,这将把它的值转换为 0 到 9 的范围。对 9 进行一次无符号比较,告诉我们字符值是否在'0'到'9'的范围内。如果是,这段代码将控制权转交给处理小数点左边数字的代码。

因为代码已将字符的 ASCII 码减去'0',所以不能简单地将字符与小数点进行比较。cmp ch, #'.'-'0'指令通过从'.'中减去'0'的字符编码来正确地比较字符是否是小数点❷。如果字符是小数点,代码将验证后面的字符是否也是数字❸。

接下来,从scanDigits开始的代码处理小数点左侧的尾数数字(如果有):

// Listing9-17.S (cont.) 
//
// Scan for digits at the beginning of the number: 

scanDigits: mov     ten, #10        // X7 used to multiply by 10 
            add     ch, ch, #'0'    // Restore character. 
 whileADigit: 
            sub     ch, ch, #'0'    // Quick way to test for 
            cmp     ch, #10         // a range and convert 
            bhs     notDigit        // to an integer 

            // Ignore any leading 0s in the number. 
            // You have a leading '0' if the mantissa is 0 
            // and the current character is '0'. 

          ❶ cmp     mant, xzr       // Ignore leading 0s. 
            ccmp    ch, #0, #0, eq 
            beq     Beyond16 

            // Each digit to the left of the decimal 
            // point increases the number by an 
            // additional power of 10\. Deal with that 
            // here. 

          ❷ add     fpExp, fpExp, #1 

            // Save all the significant digits but ignore 
            // any digits beyond the 16th digit. 

          ❸ cmp     sigDig, xzr     // X1 
            beq     Beyond16 

            // Count down the number of significant digits. 

            sub     sigDig, sigDig, #1 

 // Multiply the accumulator (mant) by 10 and 
            // add in the current digit. Note that ch 
            // has already been converted to an integer. 

          ❹ madd    mant, mant, ten, xch    // X0, X6, X5 

            // Because you multiplied the exponent by 10, 
            // you need to undo the increment of fpExp. 

          ❺ sub     fpExp, fpExp, #1 

Beyond16:   ldrb    ch, [x0, #1]!   // Get next char. 
            b.al    whileADigit 

这段代码通过检查如果尾数值为 0 且当前字符是'0',则是前导零❶,从而跳过前导零。每处理一个尾数数字,代码会通过将尾数乘以 10 并加上数字的数值来调整尾数值❹。然而,如果循环处理超过 16 个有效数字❸,它不会将字符添加到尾数累加器中(因为双精度对象支持最多 16 位有效数字)。如果输入字符串超过 16 位有效数字,代码将递增fpExp变量❷来追踪最终的指数值。如果尾数乘以了 10(这种情况下指数不需要递增),代码会在❺处撤销这一增量。

下一部分代码处理小数点后的数字:

// Listing9-17.S (cont.) 
//
// If you encountered a nondigit character, 
// check for a decimal point: 

notDigit: 
            cmp     ch, #'.'-'0'    // See if a decimal point. 
            bne     whileDigit2 

// Okay, process any digits to the right of the decimal point. 
// If this code falls through from the above, it skips the 
// decimal point. 

getNextChar: 
            ldrb    ch, [x0, #1]!   // Get the next character. 
whileDigit2: 
            sub     ch, ch, #'0' 
            cmp     ch, #10 
            bhs     noDigit2 

            // Ignore digits after the 16th significant 
            // digit but don't count leading 0s 
            // as significant digits: 

          ❶ cmp     mant, xzr            // Ignore leading 0s. 
            ccmp    ch, wzr, #0, eq 
 ccmp    sigDig, xzr, #0, eq  // X2 
            beq     getNextChar 

            // Each digit to the right of the decimal point decreases 
            // the number by an additional power of 10\. Deal with 
            // that here. 

          ❷ sub     fpExp, fpExp, #1 

            // Count down the number of significant digits: 

            sub     sigDig, sigDig, #1 

            // Multiply the accumulator (mant) by 10 and 
            // add in the current digit. Note that ch 
            // has already been converted to an integer: 

            Madd    mant, mant, ten, xch    // X1, X7, X6 
            b.al    getNextChar 

这段代码与处理左侧数字相似,不同之处在于它每处理一个数字就递减运行中的指数值❷。这是因为尾数被作为整数维护,代码通过乘以 10 并将数字的值加进去,继续将小数部分的数字插入尾数。如果有效数字的总数超过 16 位(不包括前导零❶),该函数将忽略任何后续的数字。

接下来是处理字符串的可选指数部分:

// Listing9-17.S (cont.) 

❶ noDigit2: 
            mov     expSign, wzr    // W5 Initialize exp sign. 
            mov     expAcc, xzr     // X2 Initialize exponent. 
            cmp     ch, #'e'-'0' 
            beq     hasExponent 
            cmp     ch, #'E'-'0' 
            bne     noExponent 

❷ hasExponent: 
            ldrb    ch, [x0, #1]!           // Skip the "E".
            cmp     ch, #'-'                // W6 
            cinc    expSign, expSign, eq    // W5 
            beq     doNextChar_2 
            cmp     ch, #'+' 
            bne     getExponent 

doNextChar_2: 
            ldrb    ch, [x0, #1]!   // Skip '+' or '-'. 

// Okay, you're past the "E" and the optional sign at this 
// point. You must have at least one decimal digit. 

❸ getExponent: 
            sub     ch, ch, #'0'    // W5 
            cmp     ch, #10 
            bhs     convError 

            mov     expAcc, xzr     // Compute exponent value in X2\. 
ExpLoop:    ldrb    ch, [x0], #1 
            sub     ch, ch, #'0' 
            cmp     ch, #10 
            bhs     ExpDone 

            madd    expAcc, expAcc, ten, xch    // X2, X7, X6 
            b.al    ExpLoop 

// If the exponent was negative, negate your computed result: 

❹ ExpDone: 
            cmp     expSign, #false // W5 
            beq     noNegExp 

            neg     expAcc, expAcc  // X2 

noNegExp: 

// Add in the computed decimal exponent with the exponent 
// accumulator: 

          ❺ add     fpExp, fpExp, expAcc    // X4, X2 

noExponent: 

// Verify that the exponent is from -324 to +308 (which 
// is the maximum dynamic range for a 64-bit FP value): 

          ❻ mov     x5, #308        // Reuse expSign here. 
            cmp     fpExp, x5 
            bgt     voor            // Value out of range 
            mov     x5, #-324 
            cmp     fpExp, x5 
            blt     voor 
          ❼ ucvtf   d0, mant        // X1 

这段代码首先检查是否有'e'或'E'字符,表示指数的开始❶。如果字符串有指数,代码会检查是否有可选的符号字符❷。如果存在'-'字符,代码将expSign设置为 1(默认是 0),以指定指数为负。

处理完指数符号后,代码期望看到小数点后的数字❸,并将这些数字转换为整数(保存在expAcc变量中)。如果expSign为真(非零),代码将对expAcc中的值取反❹。然后,指数代码将expAcc加到在处理尾数数字时获得的指数值上,以得到实际的指数值❺。

最后,代码检查指数是否在-324 到+308 的范围内❻。这是 64 位双精度浮点值的最大动态范围。如果指数超出此范围,代码将返回一个值超出范围的错误。

此时,代码已经完全处理了字符串数据,X0 寄存器指向内存中不再是浮动点值的第一个字节。为了将尾数和指数值从整数转换为双精度值,首先使用 ucvtf 指令❼将尾数值(在 mant 中)转换为浮动点值。

接下来,处理指数有些棘手。fpExp 变量包含十进制指数,但这是一个表示 10 的幂的整数值。你必须将 D0 中的值(尾数)乘以 10^(fpExp),但不幸的是,ARM 指令集没有提供一个可以计算 10 的整数幂的指令。你需要编写自己的代码来实现这一点:

// Listing9-17.S (cont.) 
//
// Okay, you have the mantissa into D0\. Now multiply 
// D0 by 10 raised to the value of the computed exponent 
// (currently in fpExp). 
//
// This code uses power-of-10 tables to help make the 
// computation a little more accurate. 
//
// You want to determine which power of 10 is just less than the 
// value of our exponent. The powers of 10 you are checking are 
// 10**256, 10**128, 10**64, 10**32, and so on. A slick way to 
// check is by shifting the bits in the exponent 
// to the left. Bit #8 is the 256 bit, so if this bit is set, 
// your exponent is >= 10**256\. If not, check the next bit down 
// to see if your exponent >= 10**128, and so on. 

            mov     x1, -8      // Initial index into power-of-10 table 
            cmp     fpExp, xzr  // X4 
            bpl     positiveExponent 

          ❶ // Handle negative exponents here: 

            neg     fpExp, fpExp 
            lsl     fpExp, fpExp, #55   // Bits 0..8 -> 55..63 
            lea     x6, PotTbl 
 ❷ whileExpNE0: 
            add     x1, x1, #8          // Next index into PotTbl. 
            adds    fpExp, fpExp, fpExp // (LSL) Need current POT? 
            bcc     testExp0 

            ldr     d1, [x6, x1] 
            fdiv    d0, d0, d1 

testExp0:   cmp     fpExp, xzr 
            bne     whileExpNE0 
            b.al    doMantissaSign 

// Handle positive exponents here. 

❸ positiveExponent: 
            lea     x6, PotTbl 
            lsl     fpExp, fpExp, #55       // Bits 0..8 -> 55..63 
            b.al    testExpis0_2 

whileExpNE0_2: 
            add     x1, x1, #8 
            adds    fpExp, fpExp, fpExp     // (LSL) 
            bcc     testExpis0_2 

            ldr     d1, [x6, x1] 
            fmul    d0, d0, d1 

testExpis0_2: 
            cmp     fpExp, xzr 
            bne     whileExpNE0_2 

这段代码使用了两段几乎相同的代码来处理负❶和正❸指数。两段代码的区别在于选择了 fdiv 指令(用于负指数)或 fmul 指令(用于正指数)。每个部分包含一个循环❷,该循环遍历 PotTbl(10 的幂)表中的每个条目。指数是一个 9 位值,因为最大无符号指数值为 324,可以容纳在 9 位或更少的位中。

对于此整数中的每个设置位,代码必须将浮动点结果乘以来自 PotTbl 的相应 10 的幂。例如,如果第 9 位被设置,则将尾数乘以或除以 10²⁵⁶(PotTbl 中的第一个条目);如果第 8 位被设置,则将尾数乘以或除以 10¹²⁸(PotTbl 中的第二个条目),...;如果第 0 位被设置,则将尾数乘以或除以 10⁰(PotTbl 中的最后一个条目)。代码中的两个循环通过将 9 个位移入 fpExp 的高位位置来完成此操作,然后逐位移出,并在设置进位标志时进行乘法(对于正指数)或除法(对于负指数),使用 PotTbl 中的连续条目。

接下来,如果值为负数(标志保存在 sign 变量中),则代码将其取反,并将浮动点值返回给调用者,保存在 D0 寄存器中:

// Listing9-17.S (cont.) 

doMantissaSign: 
            cmp     sign, #false            // W3 
            beq     mantNotNegative 

            fneg    d0, d0 

// Successful return here. Note: does not restore X0 
// on successful conversion. 

mantNotNegative: 
            msr     nzcv, xzr   // clr c = no error 
            ldp     x1, x2, [fp, #sr.x1x2] 
            ldp     x3, x4, [fp, #sr.x3x4] 
            ldp     x5, x6, [fp, #sr.x5x6] 
            ldr     x7,     [fp, #sr.x7x0] 
            ldr     d1,     [fp, #sr.d1  ] 
            leave 

在成功转换时,此函数返回 X0,指向浮动点字符串后面的第一个字符。此代码在成功转换时不会将 X0 恢复到原始值。

strToR64 函数的最后部分是错误处理代码:

// Listing9-17.S (cont.) 
//
// Error returns down here. Returns error code in X0: 

refNULL:    mov     x1, #-3 
            b.al    ErrorExit 

convError:  mov     x1, #-2 
            b.al    ErrorExit 

voor:       mov     x1, #-1 // Value out of range 
            b.al    ErrorExit 

illChar:    mov     x1, #-4 

// Note: on error, this code restores X0\. 

ErrorExit: 
            str     x1, [fp, #sr.x1x2]  // Return error code in X1\. 
            mrs     x1, nzcv            // Return error in carry flag. 
            orr     x1, x1, #(1 << 29) 
            msr     nzcv, x1            // Set c = error. 
            ldp     x1, x2, [fp, #sr.x1x2] 
            ldp     x3, x4, [fp, #sr.x3x4] 
            ldp     x5, x6, [fp, #sr.x5x6] 
            ldp     x7, x0, [fp, #sr.x7x0] 
            ldr     d1,     [fp, #sr.d1  ] 
            leave 

            endp    strToR64 

每个错误返回一个特殊的错误代码到 X1。所以此代码在返回时不会恢复 X1。与成功返回不同,错误返回代码将恢复 X0 到其原始值。

最后,asmMain 函数包含一个循环,处理通过 values 数组中的指针找到的每个字符串。它简单地遍历每个指针,并将其传递给 strToR64,直到遇到 NULL(0)值:

// Listing9-17.S (cont.) 

// Here is the asmMain function: 

            proc    asmMain, public 

            locals  am 
            dword   am.x20 
            byte    stack, 64 
            endl    am 

            enter   am.size 
            str     x20, [fp, #am.x20] 

// Test floating-point conversion: 

            lea     x20, values 
ValuesLp:   ldr     x0, [x20] 
            cmp     x0, xzr 
            beq     allDone 
            bl      strToR64 

            lea     x0, fmtStr1 
            ldr     x1, [x20] 
            mstr    x1, [sp] 
            mstr    d0, [sp, #8] 
            bl      printf 
            add     x20, x20, #8 
            b.al    ValuesLp 

allDone:    ldr     x20, [fp, #am.x20] 
            leave 
            endp    asmMain 

这是清单 9-17 的构建命令和示例输出:

% ./build Listing9-17 
% ./Listing9-17 
Calling Listing9-17: 
strToR64: str=' 1.234e56', value=1.234000e+56 
strToR64: str='    -1.234e+56', value=-1.234000e+56 
strToR64: str='1.234e-56', value=1.234000e-56 
strToR64: str='-1.234e-56', value=-1.234000e-56 
strToR64: str='1.23', value=1.230000e+00 
strToR64: str='-1.23', value=-1.230000e+00 
strToR64: str='001.23', value=1.230000e+00 
strToR64: str='-001.23', value=-1.230000e+00 
strToR64: str='1', value=1.000000e+00 
strToR64: str='-1', value=-1.000000e+00 
strToR64: str='0.1', value=1.000000e-01 
strToR64: str='-0.1', value=-1.000000e-01 
strToR64: str='0000000.1', value=1.000000e-01 
strToR64: str='-0000000.1', value=-1.000000e-01 
strToR64: str='0.1000000', value=1.000000e-01 
strToR64: str='-0.1000000', value=-1.000000e-01 
strToR64: str='0.0000001', value=1.000000e-07 
strToR64: str='-0.0000001', value=-1.000000e-07 
strToR64: str='.1', value=1.000000e-01 
strToR64: str='-.1', value=-1.000000e-01 
strToR64: str='123456', value=1.234560e+05 
strToR64: str='12345678901234567890', value=1.234568e+19 
strToR64: str='0', value=0.000000e+00 
strToR64: str='1.', value=1.000000e+00 
strToR64: str='0.000000000000000000001', value=1.000000e-17 
Listing9-17 terminated 

将实数到字符串以及字符串到实数的程序修改成进行“往返”转换,即从实数到字符串再到实数,看看是否能得到接近输入的相同结果,这将是一个有趣的尝试。(由于舍入和截断错误,你并不总是能得到完全相同的值,但应该会很接近。)我会留给你自己去尝试这个。

9.4 其他数值转换

本章介绍了更常见的数值转换算法:十进制整数、十六进制整数和浮动点。其他的转换有时也很有用。例如,一些应用程序可能需要八进制(基数为 8)转换或任意基数的转换。对于基数为 2 到 9 的情况,算法实际上与十进制整数转换是一样的,唯一的区别是,不是除以 10(并取余数),而是除以所需的基数。事实上,编写一个通用函数,传入基数(radix)即可获得相应的转换,是相当简单的。

当然,基数为 2 的输出几乎是微不足道的,因为 ARM CPU 内部存储的值是二进制的。你需要做的就是从数值中移出位(进入进位标志),并根据进位的状态输出 0 或 1。基数为 4 和基数为 8 的转换也相对简单,分别操作 2 位或 3 位的组。

一些浮动点格式不遵循 IEEE 标准。为了处理这些情况,可以编写一个函数,将这些格式转换为 IEEE 格式(如果可能的话),然后使用本章的示例来进行浮动点和字符串之间的转换。如果你需要直接处理这些格式,本章中的算法应该足够通用,并且易于修改以适应你的需求。

9.5 继续前进

本章详细讨论了两个主要话题:将数值转换为字符串和将字符串转换为数值。对于前者,本章介绍了数值到十六进制转换(字节、半字、字、双字和四字)、数值到无符号十进制转换(64 位和 128 位)以及数值到有符号十进制转换(64 位和 128 位)。同时,讨论了在进行数值到字符串转换时,如何通过格式化转换来控制输出格式,还讨论了格式化浮动点到字符串的转换,包括十进制和指数格式,以及计算转换所需的打印位置数。

在讨论字符串到数值的转换时,本章介绍了将无符号十进制字符串转换为数值形式、将有符号十进制字符串转换为数值形式、将十六进制字符串转换为数值形式以及将浮动点字符串转换为双精度数值形式。最后,本章简要讨论了其他可能的数值输出格式。

虽然本书将继续使用 C 语言的 printf()函数进行格式化输出,但你可以使用本章中的方法,在编写自己的汇编代码时避免依赖 C 语言。这些方法也构成了一个汇编语言库的基础,您可以利用它简化汇编代码的编写。

9.6 更多信息

  • 唐纳德·克努斯的《计算机程序设计艺术,第 2 卷:半数值算法》(第三版,Addison-Wesley Professional,1997 年)包含了许多有关十进制算术和扩展精度算术的有用信息,尽管该文本是通用的,并没有描述如何在 ARM 汇编语言中实现这些内容。

  • 关于通过乘以倒数进行除法的更多信息,请参阅爱荷华大学的教程,地址为<wbr>homepage<wbr>.cs<wbr>.uiowa<wbr>.edu<wbr>/~jones<wbr>/bcd<wbr>/divide<wbr>.html

第十章:10 表格查找

在早期的汇编语言编程中,用表格查找替代昂贵的计算是提高程序性能的常见方法。今天,现代系统中的内存速度限制了通过使用表格查找所能获得的性能提升。然而,对于非常复杂的计算,这仍然是一种编写高性能代码的可行技术。

本章讨论了如何使用表格查找来加速或减少计算的复杂度,展示了其中涉及的空间和速度的权衡。

10.1 在汇编语言中使用表格

对于汇编语言程序员来说,表格 是一个包含初始化值的数组,这些值在创建后不会发生变化。在汇编语言中,你可以使用表格来实现多种功能:计算函数、控制程序流,或仅仅用于查找数据。一般来说,表格提供了一种快速执行操作的机制,但代价是程序中占用了额外的空间(这些额外的空间存放了表格数据)。

在本节中,我们将探索在汇编语言程序中使用表格的多种可能方式。请记住,由于表格通常包含在程序执行过程中不会变化的初始化数据,因此 .section .rodata,"" 部分是放置表格对象的好地方。

10.1.1 通过表格查找进行函数计算

看似简单的 HLL 算术表达式可能等价于大量的 ARM 汇编语言代码,因此计算可能非常昂贵。汇编语言程序员通常会预先计算许多值,并通过查找这些值来加速程序,这既容易实现,通常也更高效。

考虑以下 Pascal 语句:

if (character >= 'a') and (character <= 'z') then
    character := chr(ord(character) - 32);

该 if 语句将字符变量的值从小写字母转换为大写字母(如果该字符在 a 到 z 范围内)。相应的汇编代码需要七条机器指令,具体如下:

 mov  w1, #'z'
    ldrb w0, [fp, #character]  // Assume "character" is local.
    cmp  w0, #'a'
  ❶ ccmp w0, w1, #0b0010, hs
    bhi  notLower
  ❷ eor  w0, w0, #0x20
notLower:
    strb w0, [fp, #character]

NZCV 常量 0b0010 设置进位标志并清除 0,这样当 W0 小于 'a' 时(即 W0 小于 'a' 时,进位标志被设置,零标志被清除,表示“更大或相同”但没有相同部分,因此只是更大)❶,分支会被执行。请注意,条件比较指令只允许 5 位立即数常量;这就是为什么代码将字符常量 'z' 加载到 W1 中并与 W1 进行条件比较的原因。

将小写字母转换为大写字母的常见方法是清除 ASCII 字符代码的第 5 位。但是,w0, w0, #0x5F 并不是一条合法的指令,因为 0x5F 不是一个合法的逻辑常量。该代码使用 eor(异或)指令来反转第 5 位 ❷。因为此时第 5 位必定被设置(所有小写字母的第 5 位都被设置),所以 eor 指令会清除这一位。

查找表解决方案只使用四条指令:

lea  x1, xlatTbl
ldrb w0, [fp, #character]
ldrb w0, [x1, w0, uxt2 #0]
strb w0, [fp, #character]

转换逻辑完全隐藏在查找表(xlatTbl)中。这个是一个 256 字节的数组;每个索引包含索引值(元素 0 包含值 0,元素 1 包含值 1,依此类推),除了对应小写字符 ASCII 代码的索引(索引 97 到 122)。这些特定的数组元素包含大写字母的 ASCII 代码(值 65 到 90)。

请注意,如果你可以确保只加载 7 位 ASCII 字符到此代码中,你可以使用 128 字节(而不是 256 字节)的数组来实现。

这是一个典型的(128 字节)查找表,用于将小写字母转换为大写字母:

xlatTbl:    .byte       0,1,2,3,4,5,6,7
            .byte       8,9,10,11,12,13,14,15
            .byte       16,17,18,19,20,21,22,23
            .byte       24,25,26,27,28,29,30,31
            .byte       32,33,34,35,36,37,38,39
            .byte       40,41,42,43,44,45,46,47
            .byte       48,49,50,51,52,53,54,55
            .byte       56,57,58,59,60,61,62,63
            .byte       64
            .ascii      "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
            .byte       91,92,93,94,95,96
            .ascii      "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
            .byte       123,124,125,126,127

如果你需要一个完整的 256 字节表,索引从 128 到 255 的元素将包含从 128 到 255 的值。

ldrb w0, [x1, w0, uxtw #0] 指令将 W0 加载为由 W0 中保存的(原始)值指定的索引处的字节,假设 X1 保存的是 xlatTbl 的地址。如果 W0 保存的是非小写字符的代码,索引到该表将把相同的值加载到 W0(所以如果 W0 不是小写字母,这条指令不会改变 W0 的值)。如果 W0 包含小写字母,索引到此表会获取相应大写字母的 ASCII 代码。

Listing 10-1 演示了这两种形式的大小写转换:if...eor 和表查找。

// Listing10-1.S
//
// Lowercase-to-uppercase conversion

            #include    "aoaa.inc"

            .section    .rodata, ""

ttlStr:     .asciz      "Listing 10-1"

textStr:    .ascii      "abcdefghijklmnopqrstuvwxyz\n"
            .ascii      "ABCDEFGHIJKLMNOPQRSTUVWXYZ\n"
            .asciz      "0123456789\n"

// Translation table to convert lowercase to uppercase:

xlatTbl:    .byte       0, 1, 2, 3, 4, 5, 6, 7
            .byte       8, 9, 10, 11, 12, 13, 14, 15
            .byte       16, 17, 18, 19, 20, 21, 22, 23
            .byte       24, 25, 26, 27, 28, 29, 30, 31
            .byte       32, 33, 34, 35, 36, 37, 38, 39
            .byte       40, 41, 42, 43, 44, 45, 46, 47
            .byte       48, 49, 50, 51, 52, 53, 54, 55
            .byte       56, 57, 58, 59, 60, 61, 62, 63
            .byte       64
            .ascii      "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
            .byte       91, 92, 93, 94, 95, 96
            .ascii      "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
            .byte       123, 124, 125, 126, 127

// Various printf format strings this program uses:

fmtStr1:    .asciz      "Standard conversion:\n"
fmtStr2:    .asciz      "\nConversion via lookup table:\n"
fmtStr:     .asciz      "%c"

            .code
            .extern     printf

////////////////////////////////////////////////////////////////////
//
// Return program title to C++ program:

            proc        getTitle, public
            lea         x0, ttlStr
            ret
            endp        getTitle

////////////////////////////////////////////////////////////////////
//
// Here is the asmMain function:

            proc    asmMain, public

            locals  am
            dword   am.x20
            dword   am.x21
            byte    am.shadow, 64
            endl    am

            enter   am.size
            str     x20, [fp, #am.x20]
            str     x21, [fp, #am.x21]

// Print first title string:

            lea     x0, fmtStr1
            bl      printf

// Convert textStr to uppercase using
// standard "if and EOR" operation:

            lea     x20, textStr    // String to convert
            mov     x21, #'z'       // CCMP doesn't like #'z'.
            b.al    testNot0

// Check to see if W1 is in the range 'a'..'z'. If so,
// invert bit 5 to convert it to uppercase:

stdLoop:    cmp     w1, #'a'
            ccmp    w1, w21, #0b0010, hs
            bhi     notLower
            eor     w1, w1, #0x20
notLower:

// Print the converted character:

            lea     x0, fmtStr
            mstr    x1, [sp]
            bl      printf

// Fetch the next character from the string:

testNot0:   ldrb    w1, [x20], #1
            cmp     w1, #0
            bne     stdLoop

// Convert textStr to uppercase by using
// a lookup table. Begin by printing
// an explanatory string before the
// output:

            lea x0, fmtStr2
            bl      printf

// textStr is the string to convert.
// xlatTbl is the lookup table that will convert
// lowercase characters to uppercase:

            lea     x20, textStr
            lea     x21, xlatTbl
            b.al    testNot0a

// Convert the character from lowercase to
// uppercase via a lookup table:

xlatLoop:   ldrb    w1, [x21, w1, uxtw #0]

// Print the character:

            lea     x0, fmtStr
            mstr    x1, [sp]
            bl      printf

// Fetch the next character from the string:

testNot0a:  ldrb    w1, [x20], #1
            cmp     w1, #0
            bne     xlatLoop

allDone:    ldr     x20, [fp, #am.x20]
            ldr     x21, [fp, #am.x21]
            leave   // Returns to caller
            endp    asmMain

这是 Listing 10-1 的构建命令和示例输出:

% ./build Listing10-1
% ./Listing10-1
Calling Listing10-1:
Standard conversion:
ABCDEFGHIJKLMNOPQRSTUVWXYZ
ABCDEFGHIJKLMNOPQRSTUVWXYZ
0123456789

Conversion via lookup table:
ABCDEFGHIJKLMNOPQRSTUVWXYZ
ABCDEFGHIJKLMNOPQRSTUVWXYZ
0123456789
Listing10-1 terminated

我没有尝试对两种版本进行计时,因为对 printf() 的调用主导了两个算法的执行时间。然而,由于查找表算法在每个字符上访问内存(从查找表中获取字节),即使它使用了更少的指令,过程也并不更短。查找表增加了程序代码的 128 字节(或 256 字节)。

对于像小写字母转大写字母这样的简单计算,使用查找表几乎没有什么好处。但随着计算复杂度的增加,查找表算法可能会变得更快。考虑以下交换大小写的代码(将小写字母转换为大写字母,反之亦然):

// If it's lowercase, convert it to uppercase:

    mov  w1, #'z'
    ldrb w0, [fp, #character]  // Assume "character" is local.
    cmp  w0, #'a'
    ccmp w0, w1, #0b0010, hs
 bhi  notLower
    eor  w0, w0, #0x20
    b.al allDone

// If it's uppercase, convert it to lowercase:

notLower:
    mov  w1, #'Z'
    cmp  w0, #'A'
    ccmp w0, w1, #0b0010, hs
    bhi  allDone
    eor  w0, w0, #0x20

allDone:
    strb w0, [fp, #character]

查找表版本几乎与 Listing 10-1 相同。只是查找表中的值发生了变化:

 lea  x1, xlatTbl2
    ldrb w0, [fp, #character]
    ldrb w0, [x1, w0, uxtw #0]
    strb w0, [fp, #character]

xlatTbl2 数组将包含与大写字母对应的索引位置的小写 ASCII 代码,同时也会在与小写 ASCII 代码对应的索引位置保存大写 ASCII 代码。

这个大小写转换算法可能仍然不足够复杂,无法证明使用查找表来提高性能是合理的。然而,它表明随着算法复杂度的增加(如果没有查找表则执行时间更长),查找表算法的执行时间保持恒定。

10.1.2 函数域与范围

通过查找表计算的函数有一个有限的定义域,即它们接受的所有可能输入值的集合。这是因为函数定义域中的每个元素都需要在查找表中有一个条目。例如,之前的大小写转换函数的定义域是 256 字符的扩展 ASCII 字符集。像 sin()或 cos()这样的函数接受的是(无限的)实数集作为可能的输入值。你不会发现通过查找表实现一个定义域为实数集的函数非常实用,因为你必须将定义域限制为一个较小的集合。

大多数查找表相当小,通常只有 10 到 256 个条目。它们很少会超过 1,000 个条目。大多数程序员没有足够的耐心去创建和验证一个 1,000 条目的表(但请参阅第 10.1.4 节,“表生成”,在第 615 页讨论如何通过编程生成表)。

基于查找表的函数的另一个限制是,定义域中的元素必须相当连续。查找表使用函数的输入值作为查找表的索引,并返回该条目处的值。一个接受 0、100、1,000 和 10,000 作为输入值的函数需要 10,001 个元素,因为输入值的范围。因此,你不能通过查找表高效地创建这样的函数。本节讨论的查找表假设函数的定义域是一个相当连续的值集。

一个函数的范围是它所产生的所有可能输出值的集合。从查找表的角度来看,函数的范围决定了每个表项的大小。例如,如果函数的范围是整数值 0 到 255,则每个表项需要一个字节;如果范围是 0 到 65,535,则每个表项需要 2 个字节,依此类推。

你可以通过查找表实现的最佳函数是那些其定义域和范围始终为 0 到 255(或该范围的子集)的函数。任何这样的函数都可以通过以下两条指令来计算:

lea x1, table
ldrb w0, [x1, w0, uxtw #0]

唯一改变的是查找表。之前介绍的大小写转换程序是这种函数的好例子。

如果函数的定义域或范围不是 0 到 255,查找表的效率会稍微降低。如果一个函数的定义域超出了 0 到 255 的范围,但其范围落在该值集合之内,那么你的查找表将需要超过 256 个条目,但你可以用一个字节表示每个条目。因此,查找表可以是一个字节数组。C/C++函数调用

B = Func(X);

其中 Func 是

byte Func(word `parm)` {...}

它可以很容易地转换为以下 ARM 代码:

lea  x1, FuncTbl
ldr  w0, X       // Using appropriate addressing mode
ldrb w0, [x1, w0, uxtw #0]
strb w0, B       // Using appropriate addressing mode

这段代码将函数参数加载到 W0 中,使用该值(在 0 到maxParmValue 范围内)作为 FuncTbl 表的索引,获取该位置的字节,并将结果存储到 B 中。显然,表中必须为 X 的每个可能值(最多为 maxParmValue)包含有效条目。例如,假设你想要将 80 × 25 文本视频显示器上的光标位置(范围为 0 到 1999,80 × 25 显示器有 2000 个字符位置)映射到屏幕上的 X(0 到 79)或 Y(0 到 24)坐标。

你可以通过这个函数计算 X 坐标

X = Posn % 80;

并使用以下公式计算 Y 坐标:

Y = Posn / 25;

以下代码通过表查找实现这两个函数,可能会提高代码的性能,特别是在频繁访问表且表位于处理器缓存中的情况下:

lea  x2, xTbl
lea  x3, yTbl
ldr  w4, Posn   // Using an appropriate addressing mode
ldrb w0, [x2, w4, uxtw #0] // Get X.
ldrb w1, [x3, w4, uxtw #0] // Get Y.

给定 xTbl 和 yTbl 中的适当值,这将把 x 坐标留在 W0 中,y 坐标留在 W1 中。

如果一个函数的定义域在 0 到 255 之间,但其值域超出该范围,查找表将包含 256 个或更少的条目,但每个条目将需要 2 个或更多字节。如果函数的值域和定义域都超出 0 到 255 的范围,每个条目将需要 2 个或更多字节,并且表格将包含超过 256 个条目。

回顾第四章,索引单维数组(表格是特殊情况)的公式如下:

`Element_Address` = `Base` + `Index` × `Element_Size`

如果函数值域中的元素需要 2 个字节,你必须将索引乘以 2,然后才能索引到表中。同样,如果每个条目需要 3、4 或更多字节,则在作为索引使用之前,必须将索引乘以每个表项的字节大小。例如,假设你有一个函数 F(x),由以下 C/C++ 声明定义:

short F(word x) {...} // short is a half word (16 bits).

你可以通过使用以下 ARM 代码创建这个函数(当然,还有合适的表格,名为 F):

lea  x1, F
ldrh w0, x    // Using an appropriate addressing mode
ldrh w0, [x1, w0, uxtw #1] // Shift left does multiply by 2.

任何定义域较小且大多是连续的函数,都适合通过表查找进行计算。在某些情况下,不连续的定义域也是可以接受的,只要能够将定义域转化为合适的值集合(之前讨论过的例子是处理 switch 语句的表达式)。这种操作叫做条件化,是下一节的主题。

10.1.3 定义域条件化

定义域条件化 是指将函数定义域中的一组值进行处理,使它们更适合作为该函数的输入。考虑以下函数:

sin x = sin x|(x∈[-2π,2π])

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

-2π <= x <= 2π

正如你所知道的,正弦是一个循环函数,它可以接受任何实数输入。然而,用于计算正弦的公式只接受这些值中的一小部分。这个范围限制不会带来实际问题;只需通过计算 sin(y mod (2π)),你就能计算出任何输入值的正弦。修改输入值,以便你能够轻松计算函数的过程叫做 输入条件调整。前面的例子计算了 (x % 2) * pi,并将结果作为 sin() 函数的输入。这会将 x 截断到 sin() 所需的领域,而不会影响结果。

你也可以将输入条件调整应用于表查找。实际上,缩放索引以处理字条目就是一种输入条件调整。考虑以下 C/C++ 函数:

short val(short x)
{
    switch (x)
    {
        case 0: return 1;
        case 1: return 1;
        case 2: return 4;
        case 3: return 27;
        case 4: return 256;
    }
    return 0;
}

这个函数计算 x 在 0 到 4 范围内的值,如果 x 超出此范围,则返回 0。由于 x 可以取 65,536 个值(作为一个 16 位字),创建一个包含 65,536 个字的表,其中只有前五个条目非零,似乎非常浪费。然而,如果使用输入条件调整,你仍然可以通过表查找来计算这个函数。以下汇编语言代码展示了这个原理:

 mov  w0, #0       // Result = 0, assume x > 4
    ldrh w1, [fp, #x] // Assume x is local.
 cmp  w1, #4       // See if in the range 0 to 4.
    bhi  outOfRange
    lea  x2, valTbl   // Address of lookup table
    ldrh w0, [x2, w1, uxtw #1] // index * 2 (half-word table)
outOfRange:

这段代码检查 x 是否超出 0 到 4 的范围。如果超出,它会手动将 W0 设置为 0;否则,它会通过 valTbl 表查找函数值。通过输入条件调整,你可以实现一些通过表查找通常无法做到的功能。

10.1.4 表格生成

使用表查找的一个大问题是首先创建表格。如果表格包含许多条目,这个问题尤其突出。弄清楚表格中应该放入哪些数据,然后繁琐地输入这些数据,最后检查数据以确保其有效性,是一个既耗时又枯燥的过程。

对于许多表来说,这是无法避免的。然而,对于其他表,你可以利用计算机为你生成表。我将通过一个例子来解释这一点。考虑以下对正弦函数的修改:

\(方程\)

这说明 x 是一个在 0 到 359(度)范围内的整数,并且 r 必须是一个整数。计算机可以通过以下代码轻松计算此内容:

lea   x1, Sines     // Table of 16-bit values
ldr   w0, [fp, #x]  // Assume x is local.
ldrh  w0, [x1, w0, uxtw #1]  // index * 2 for half words
ldrh  w2, [fp, #r]  // Assume r is local.
sxth  x0, w0
sxth  x2, w2
smul  w0, w0, w2    // r *(1000 * sin(x))
mov   w2, #1000
sdiv  x0, x0, x2    // r *(1000 * sin(x))/ 1000

请注意,整数的乘法和除法不是结合律的。你不能简单地去掉乘以 1,000 和除以 1,000,因为它们看似互相抵消。此外,这段代码必须严格按照这个顺序来计算该函数。

完成此功能所需的所有内容是正弦值,一个包含 360 个值的表,表示角度(以度为单位)的正弦值乘以 1,000。列表 10-2 中的 C/C++ 程序生成了这个表。

// Listing10-2.cpp
//
// g++ -o Listing10-2 Listing10-2.c -lm
//
// GenerateSines
//
// A C program that generates a table of sine values for
// an assembly language lookup table

#include <stdlib.h>
#include <stdio.h>
#include <math.h>

int main(int argc, char **argv)
{
    FILE *outFile;
    int angle;
    int r;

    // Open the file:

    outFile = fopen("sines.inc", "w");

    // Emit the initial part of the declaration to
    // the output file:

    fprintf
    (
        outFile,
        "Sines:"  // sin(0) = 0
    );

    // Emit the Sines table:

    for(angle = 0; angle <= 359; ++angle)
    {
        // Convert angle in degrees to an angle in
        // radians using:
        //
        // radians = angle * 2.0 * pi / 360.0;
        //
        // Multiply by 1000 and store the rounded
        // result into the integer variable r.

        double theSine =
            sin
            (
                angle * 2.0 *
                3.14159265358979323846 /
                360.0
            );
        r = (int) (theSine * 1000.0);

        // Write out the integers eight per line to the
        // source file.
        // Note: If (angle AND %111) is 0, then angle
        // is divisible by 8 and you should output a
        // newline first.

 if((angle & 7) == 0)
        {
            fprintf(outFile, "\n\t.hword\t");
        }
        fprintf(outFile, "%5d", r);
        if ((angle & 7) != 7)
        {
            fprintf(outFile, ",");
        }

    } // endfor
    fprintf(outFile, "\n");

    fclose(outFile);
    return 0;

} // end main

编译并运行列表 10-2 中的程序会生成文件 sines.inc,该文件包含以下内容(为了简洁起见,已截断):

Sines:
     .hword      0,   17,   34,   52,   69,   87,  104,  121
     .hword    139,  156,  173,  190,  207,  224,  241,  258
     .hword    275,  292,  309,  325,  342,  358,  374,  390
     .hword    406,  422,  438,  453,  469,  484,  499,  515
     .hword    529,  544,  559,  573,  587,  601,  615,  629
     .hword    642,  656,  669,  681,  694,  707,  719,  731
     .hword    743,  754,  766,  777,  788,  798,  809,  819
        .
        .
        .
     .hword   -529, -515, -500, -484, -469, -453, -438, -422
     .hword   -406, -390, -374, -358, -342, -325, -309, -292
     .hword   -275, -258, -241, -224, -207, -190, -173, -156
     .hword   -139, -121, -104,  -87,  -69,  -52,  -34,  -17

显然,编写生成这些数据的 C 程序比手动输入和验证这些数据要容易得多。你还可以使用 Pascal/Delphi、Java、C#、Swift 或其他高级语言(HLL)编写表格生成程序。由于该程序只会执行一次,因此其性能不是问题。

一旦你运行了表格生成程序,剩下的步骤就是从文件(本示例中的sines.inc)中剪切并粘贴表格到实际使用该表格的程序中(或者,使用#include "sines.inc"指令将文本包含到源文件中)。

10.2 表格查找性能

在早期的 PC 时代,表格查找是进行高性能计算的首选方式。如今,CPU 的速度通常是主存储器的 10 到 100 倍。因此,使用表格查找可能不比使用机器指令进行相同计算更快。然而,片上 CPU 缓存内存子系统的速度接近 CPU 速度。因此,如果你的表格存储在 CPU 的缓存内存中,表格查找可能是成本效益较高的选择。这意味着,从表格查找中获得良好性能的方法是使用小表格(因为缓存空间有限)并使用你频繁访问的表格项(以便表格保持在缓存中)。

最终,确定表格查找是否比计算更快的最佳方法是编写两种版本的代码并进行计时。尽管“1000 万次循环计时”方法可能足够用于粗略测量,但你可能还希望找到并使用一个合适的性能分析工具,它将提供更精确的计时结果。有关更多详细信息,请参阅“更多信息”部分。

10.3 继续前进

随着 CPU 速度的提升和内存访问时间未能跟上,使用表格查找来优化应用程序已逐渐不再流行。然而,本章简短地讨论了表格查找仍然有用的情况。它首先讨论了基本的表格查找操作,然后讲解了领域条件化和使用软件自动生成表格。最后总结了几条关于如何判断表格查找是否适合特定项目的建议。

在现代 CPU 中,多个核心和 SIMD 指令集是提高应用程序性能的常见方式。下一章将讨论 ARM Neon/SIMD 指令集,以及如何使用它来提高程序性能。

10.4 更多信息

第十一章:11 NEON 和 SIMD 编程

本章讨论了 ARM 上的矢量指令。这一特殊类型的指令提供了并行处理,传统上被称为 单指令多数据(SIMD) 指令,因为,字面上说,一条指令可以同时作用于多个数据单元。由于这种并发性,SIMD 指令通常比标准 ARM 指令集中的 单指令单数据(SISD) 指令执行速度快得多(理论上,可以快 32 到 64 倍)。

矢量指令,也称为 Neon 指令集ARM 高级 SIMD,为标准标量指令提供了扩展。标准的 标量指令 每次操作一个数据单元,而 Neon 指令则同时操作一个数据对象的 矢量(即数组)。

本章简要回顾了 SIMD 指令的历史,然后讨论了 ARM Neon 架构(包括矢量寄存器)和 Neon 数据类型。接下来的大部分内容将讲解 Neon 指令集。关于 SIMD 编程的完整论述超出了本书的范围;然而,写这章时至少提供一些 SIMD 编程示例以展示 SIMD 编程的优势是必要的,因此本章的最后将给出一些例子,展示比托尼排序和数字到十六进制字符串的转换。

11.1 SIMD 指令扩展的历史

Neon 指令集扩展是在 ARM 创建之后很久才加入到 ARM 指令集中的。ARM 创建 Neon 是为了应对英特尔 x86 CPU 家族的竞争。要理解为什么 Neon 指令集与标准指令集有如此根本的不同,必须了解 SIMD(矢量)指令集的历史。

第一个矢量计算机是超级计算机,如 CDC Star-100、德州仪器的高级科学计算机(ASC)和 Cray 计算机,它们可以用单条指令操作一个数据矢量。这些矢量计算机是早期 SIMD 计算机的前身,如 Thinking Machines 的 CM-1 和 CM-2。最终,随着英特尔在其低成本 i860(后来是 Pentium 处理器)上引入 SIMD 特性,超级计算机开始远离 SIMD 方法。

英特尔多媒体扩展(MMX)指令集是第一个广泛采用的桌面 SIMD 架构。英特尔将并行整数运算指令添加到传统的 x86 指令集中,以加速数字音频处理和其他数字信号处理应用。之后,PowerPC 推出了更强大的 AltiVec 架构(其中包括对单精度浮点值的支持)。随后,英特尔推出了 SSE2 和 SSE3、AVX、AVX2 和 AVX-512 SIMD 指令架构(现在包括完全的双精度浮点支持)。

英特尔在其 x86 系列 CPU 中添加向量指令的方法有些拗口。由于上世纪 90 年代中期 CPU 的晶体管预算有限,英特尔在其早期的奔腾处理器中增加了一些向量指令(MMX),然后随着其 CPU 的增大和可用晶体管数量的增加,扩展了 SIMD 指令集,支持实现更先进的特性。这一演变产生了一些杂乱无章的情况,新的指令集复制并淘汰了旧的指令(新指令集能够处理更多的数据或以不同的方式处理数据)。

到了 ARM 通过其 Neon 高级 SIMD 指令添加 SIMD 指令时,英特尔已经经历了多代 SIMD 指令;ARM 能够从英特尔的指令集中挑选出更有趣且实用的指令,抛弃了所有不必要的遗留指令。因此,Neon 指令集比英特尔的 MMX/SSE/AVX 指令集要简洁得多,理解起来也容易得多。

11.2 向量寄存器

ARM 提供了 32 个主 FP/Neon 寄存器,每个大小为 128 位,按其大小分为五组:

  • V0 到 V31,为 128 位向量寄存器(用于 Neon 指令),也可以引用为 Q0 到 Q31,qword 寄存器;Vn 名称支持向量操作的特殊语法

  • D0 到 D31,为 64 位双精度浮点寄存器

  • S0 到 S31,为 32 位单精度浮点寄存器

  • H0 到 H31,为 16 位半精度浮点寄存器

  • B0 到 B31,为 8 位字节寄存器

图 11-1 显示了向量寄存器的布局。

图 11-1:FP/Neon 寄存器

Bn、Hn、Sn、Dn 和 Vn 寄存器彼此重叠,如 图 11-2 所示。

图 11-2:字节、半字、单精度、双精度和向量寄存器重叠

有关标量浮点寄存器 Dn、Sn 和 Hn 的更多信息,请参见 第六章。不过请记住,如果在代码中混合使用向量和浮点操作,则这些指令共享相同的寄存器集合。

图 11-1 和 图 11-2 给人一种印象,即 Vn 寄存器是 128 位寄存器(可以作为一个 128 位的值进行操作)。实际上,Vn 寄存器是包含 16 个 8 位、8 个 16 位、4 个 32 位、2 个 64 位或(单个)128 位值的向量,正如 图 11-3 所示。

图 11-3:向量寄存器重叠

当指令操作某个向量寄存器的特定元素时,可以通过以下寄存器名称引用该元素(在所有情况下,n 代表一个向量寄存器编号,范围从 0 到 31):

  • Vn 或 Qn 当引用整个 128 位寄存器时

  • Vn.B 当将整个寄存器视为 16 字节的数组时

  • 当将整个寄存器视为八个半字组成的数组时,使用 Vn.H。

  • 当将整个寄存器视为四个字(单精度值)组成的数组时,使用 Vn

  • 当将整个寄存器视为两个双字(双精度值)组成的数组时,使用 Vn.D。

  • 当引用位位置 0 到 63 或 64 到 127 中的 64 位双精度时,使用 Vn.2D[0]或 Vn.2D[1]。

  • 当访问位位置 0 到 31、32 到 63、64 到 95 或 96 到 127 中的 32 位单精度值时,使用 Vn.4S[0]、Vn.4S[1]、Vn.4S[2]、Vn.4S[3]。

  • 当访问位位置 0 到 15、16 到 31、...、112 到 127 中的 16 位半字值时,使用 Vn.8H[0]、Vn.8H[1]、...、Vn.8H[7]。

  • 当访问位位置 0 到 7、8 到 15、...、120 到 127 中的 8 位字节时,使用 Vn.16B[0]、Vn.16B[1]、...、Vn.16B[15]。

选择确切名称将取决于指令和情况。你将在下一节“向量数据移动指令”中看到这些寄存器的使用示例,特别是 11.3.4 节“向量加载与存储”,在第 632 页。

图 11-2 和图 11-3 展示了与向量寄存器中数据相关的五种基本类型:字节、半字、单精度值、双精度值和 128 位四字(qword)。事实上,32 位(单精度)和 64 位(双精度)字段支持浮点(单精度和双精度)和整数(字和双字)类型,将类型的总数增加到七种。

除了特殊的 128 位情况外,向量寄存器包含字节、半字、字和四字(qword)的数组。由于你将在本章讨论向量操作时了解的原因,每个数组元素被称为lane。在使用两个向量寄存器执行操作时,CPU 通常通过使用两个源寄存器中对应的 lane 中的操作数来计算结果,并将结果存储在目标寄存器中的相应 lane 中。例如,假设 V1 在高 64 位(lane 1)包含 2.0,在低 64 位(lane 0)包含 1.0,而 V2 在 lane 1 中包含 20.0,在 lane 0 中包含 10.0。将这两个向量寄存器相加并将结果存储在 V3 中,会在 lane 1 中得到 22.0,在 lane 0 中得到 11.0。

尽管向量寄存器通常包含数据数组(在执行 SIMD 操作时),但不要忘记,浮点寄存器(Dn和 Sn)也覆盖了向量寄存器。当执行正常的浮点操作时(参见第六章),这些寄存器只包含单一的值,而不是值的数组。这些单一值被称为标量

很少有操作将整个 128 位 Neon 寄存器视为标量值。那些将其视为标量值的操作(主要是加载和存储指令)使用名称 Qn来表示标量值,而不是 Vn(向量值)。

11.3 向量数据移动指令

移动指令是 Neon 指令集中的最常见整数和浮点指令。在这一节中,你将学习如何使用这些指令在寄存器之间移动数据、将常量加载到 Neon 寄存器中,以及将向量寄存器从内存加载和存储。

11.3.1 寄存器之间的数据传输

你可以使用 mov 指令在向量寄存器之间移动数据。不幸的是,显然的语法无法正常工作:

mov v0, v1  // Generates a syntax error

mov 指令将向量元素复制到向量寄存器。它可以在两个向量寄存器之间复制数据,或者在通用寄存器(Xn 或 Wn)和向量寄存器之间复制数据。具体语法取决于你要复制的数据量以及源寄存器和目标寄存器的位置(向量寄存器或通用寄存器)。

将数据从 32 位通用寄存器(Wm)移动到向量寄存器(Vn)使用以下语法之一:

mov V`n`.B[`i`], W`m`  // Inserts LO byte of W`m` into V`n`[`i`] (`i` = 0 to 15)
mov V`n`.H[`i`], W`m`  // Inserts LO hword of W`m` into V`n`[`i`] (`i` = 0 to 7)
mov V`n`.S[`i`], W`m`  // Inserts W`m` into V`n`[`i`] (`i` = 0 to 3)

索引 i 必须是一个字面整数常量,如下例所示:

mov v0.b[15], w0  // Copy LO byte of W0 into lane 15 of V0.
mov v1.h[0], w2   // Copy LO hword of W2 into lane 0 of W1.
mov v2.s[2], w1   // Copy W1 into lane 2 of V2.

将数据从 64 位通用寄存器(Xm)移动到向量寄存器(Vn)使用以下语法:

mov V`n`.D[`i`], X`m`   // Inserts X`m` into V`n`[`i`] (`i` = 0 to 1)

我在这些示例中使用了插入这个词,因为 mov 指令只将字节、半字、字或双字复制到向量寄存器中,由索引 i 指定的位置。它不会影响 Vn 中的其他数据。例如:

mov v0.b[4], w0

只将 W0 中的 LO 字节插入到 V0 寄存器的第 4 个 lane;它不会更改 V0 中的其他字节。只有使用 Wm 寄存器时,才能移动字节、半字和字。如果你在指令中使用 Xm,你只能移动 64 位。向量寄存器的类型指定为 S(单精度)用于 32 位,D(双精度)用于 64 位。即使是在复制 32 位和 64 位整数时,你也要使用这个指定。

注意

ARM 指令 ins (插入) 是 mov 的同义词,用于将数据从通用寄存器复制到向量寄存器——这也是我们说这些指令插入数据而不是说它们复制数据的另一个原因。

之前的示例将值从 32 位或 64 位通用寄存器复制到向量寄存器。你还可以通过以下语法将数据从一个向量寄存器(Vn)复制到另一个向量寄存器(Vm):

mov V`m`.8B, V`n`.8B    // Copy 64 bits.
mov V`m`.16B, V`n`.16B  // Copy 128 bits.

这些指令将 64 位(8 字节,四个半字,两个字或一个双字)或 128 位(16 字节,八个半字,四个单精度值或两个双字/双精度值)从一个向量寄存器复制到另一个向量寄存器。理论上,你应该能够输入类似 mov v1, v0 或 mov q1, q0 的语法,将 128 位向量寄存器 V0(Q0)的内容移动到 V1(Q1)。可惜的是,Gas 不接受这种语法,因此你需要使用之前的四个指令之一,如下例所示:

mov v0.16b, v1.16b  // Copies V1 to V2

你还可以从一个向量寄存器中提取单个字节,并使用以下语法将其插入到另一个向量寄存器的任意 lane 中:

mov V`m`.B[`i1`], V`n`.B[`i2`]

其中 i2 是源向量中字节的索引,i1 是目标索引。两个索引必须在 0 到 15 的范围内。

你还可以从一个向量中提取半字并将其插入到另一个向量中:

mov V`m`.H[`i1`], V`n`.H[`i2`]

对于字节,规则相同,只是两个索引值必须在 0 到 7 的范围内。

你可以使用以下语法复制字(单精度值)和双字(双精度值):

mov V`m`.S[`i1`], V`n`.S[`i2`]  // i1 and i2 must be in range 0 to 3.
mov V`m`.D[`i3`], V`n`.D[`i4`]  // i3 and i4 must be in range 0 to 1.

这是一个示例,展示了如何将 V0 和 V1 的低 64 位(LO dwords)复制并合并到 V2 的两个 64 位中:

mov v2.d[0], v0.d[0]
mov v2.d[1], v1.d[0]

到目前为止,我已经描述了如何将数据从通用寄存器移动到向量寄存器,以及在两个向量寄存器之间移动数据。唯一缺少的组合是将数据从向量寄存器移动到通用寄存器,这是通过以下的 movumovsmov 指令来实现的:

mov  W`n`, V`m`.S[`i0`]  // Copies 32 bits
mov  X`n`, V`m`.D[`i1`]  // Copies 64 bits

umov W`n`, V`m`.B[`i1`]  // Zero-extends byte to 32 bits
umov W`n`, V`m`.H[`i2`]  // Zero-extends hword to 32 bits
umov X`n`, V`m`.D[`i3`]  // Copies 64 bits

smov W`n`, V`m`.B[`i5`]  // Sign-extends byte to 32 bits
smov W`n`, V`m`.H[`i6`]  // Sign-extends hword to 32 bits
smov X`n`, V`m`.B[`i5`]  // Sign-extends byte to 64 bits
smov X`n`, V`m`.H[`i6`]  // Sign-extends hword to 64 bits
smov X`n`, V`m`.S[`i7`]  // Sign-extends word to 64 bits

没有将 8 位或 16 位扩展到 64 位的零扩展操作。将数据零扩展到 Wn 会自动将零扩展到 Xn 的上 32 位。以下是这些指令的示例:

mov  w0, v0.s[0]  // Copy lane 0 (word) of V0 to W0.
mov  x1, v7.d[1]  // Copy lane 1 (dword) of V7 to X1.
umov w0, v1.b[2]  // Copy and zero-extend V1[2] byte to W0.
smov x1, v0.s[3]  // Copy and sign-extend V0[3] (word) to X1.

请记住,smov x1, v0.s[3] 是将一个整数值移动到寄存器中,尽管指定的类型是 S(单精度)。

11.3.2 向量加载立即数指令

ARM CPU 提供了一组有限的指令,允许将某些立即数常量加载到向量寄存器中。这些指令的整数版本只允许使用无符号的 8 位立即操作数,可以直接使用或左移 1、2 或 3 字节(用 0 或 1 填充空出的位)。此外,这些立即数指令将数据复制到字节数组、半字数组或字数组的每个通道中。浮点版本的这些指令允许有限的浮点常量(与标量浮点常量的限制相同;请参见第 6.9.1.4 节“带立即操作数的 fmov”在第 334 页)。

标准的 立即数移动 指令是 movi

movi V`n`.`size`, #`uimm8`
movi V`n`.`size`, #`uimm8`, lsl #`c` // `size` = 4H, 8H, 2S, or 4S
movi V`n`.`size`, #`uimm8`, msl #`c` // `size` = 2S or 4S
movi V`n`.2D, #`uimm64`
movi D`n`, #`uimm64`

其中,size 可以是 16B、8B、4H、8H、2S 或 4S;uimm8 是一个 8 位常量;uimm64 是 0 或 0xFFFFffffFFFFffff。对于 4H、8H、2S 和 4S 大小的指令,lsl #c 组件是可选的。对于 2S 和 4S 大小,msl #c 选项是可选的。movi 指令将指定的立即数初始化到向量寄存器的所有通道中,或者只初始化 LO 64 位的通道。接下来的段落将描述每个指令的具体变体。

movi Vn.8B, #uimm8 指令将指定的常量填充到 Vn 的 LO 8 字节中,并将寄存器的 HO 64 位填充为 0。例如:

movi v0.8b, #0x80

将 0x80808080 加载到 V0 中。

movi Vn.16B, #uimm8 指令将指定的常量填充到 Vn 的所有 16 个字节中。每个通道都会接收 uimm8 值的副本。

movi Vn.4H, #uimm8 指令将指定的 uimm8 常量填充到 Vn 的 LO 64 位中的四个半字通道,并将 Vn 的 HO 64 位填充为 0。由于该指令仅接受 8 位立即数常量,因此每个半字通道的 HO 8 位将包含 0。例如:

movi v1.4h, #1

将 0x0001000100010001 加载到 V1。

movi Vn.4H, #uimm8, lsl #0 指令与 movi Vn.4H, #uimm8 完全相同。如果移位常量是 #8,则该指令将在将立即数存储到四个半字通道(Vn 的低 64 位)之前,将立即数向左移位 8 个位置。在这种情况下,这些通道的每个低 8 位将包含 0。例如:

movi v1.4h, #1, lsl #8

将 0x0100010001000100 加载到 V1。

movi Vn.8H, #uimm8 和 movi Vn.8H, #uimm8, lsl #c 指令与 4H 指令做的事情相同,唯一不同的是它们将立即数(通过 0 或 8 位移位)存储到 Vn 寄存器的所有八个通道中。

movi Vn.2S, #uimm8 指令将 Vn 的低 64 位中的两个字(单精度)通道填充为 uimm8 常量,并将 Vn 的高 64 位填充为 0。因为该指令仅接受 8 位立即数常量,所以每个字通道的高 24 位将包含 0。尽管类型说明符为 S,但该指令为通道分配的是整数常量,而非浮点常量。如果存在可选的移位子句(movi Vn.2S, #uimm8, lsl #c,其中 c 是 0、8、16 或 24),该指令将在将常量存储到两个通道之前,按指定的位数移位 8 位常量。以下是一些示例:

movi v3.2s, #1          // Loads 0x0000000100000001 into V3
movi v4.2s, #1, lsl #8  // Loads 0x0000010000000100 into V4
movi v5.2s, #1, lsl #16 // Loads 0x0001000000010000 into V5
movi v6.2s, #1, lsl #24 // Loads 0x0100000001000000 into V6

movi Vn.2S, #uimm8, msl #c 指令几乎与其 lsl 对应指令相同,唯一的区别是它在左移操作中将 1 位而非 0 位移入空出的位。移位计数限制为 8 或 16,而不是 0、8、16 和 24(一个令人烦恼的不一致)。例如:

movi v5.2s, #1, msl #16

将 0x0001FFFF0001FFFF 加载到 V5。

movi Vn.4S, #uimm8 指令将 uimm8 常量复制到 Vn 中的四个字(单精度)通道。否则,该指令(以及带移位的变体)与 2S 版本的行为完全相同。

movi Vn.2D, #uimm64 指令将常量 0 或 -1 加载到 Vn 寄存器的两个双字通道中。再次提醒,这些是整数常量,而不是浮点常量,尽管使用了 2D 类型说明符。

第二条立即数移动指令是 mvni(移动并立即数)。它支持以下语法:

mvni V`n`.`size`, #`uimm8` {, (lsl | msl) #`c`}

其中,size 和 uimm8 的含义与 movi 中给出的含义相同。

操作与 movi 相同,不同之处在于 mvni 在将数据存储到 Vn 目标寄存器的通道中之前,会将所有位反转。Vn 的高 64 位仍会为 4H 和 2S 类型说明符接收 0,如以下示例所示:

mvni v2.4h, #1, lsl #8  // Loads 0xFEFFfeffFEFFfeff into V2
mvni v4.2s, #2, msl #8  // Loads 0xFFFFFD00fffffd00 into V4

注意 mvni 指令没有 2D 类型。这些指令是不必要的,因为两个允许的 movi uimm64 常量已经是彼此的反向。如果你想要反转的位,只需使用另一个 uimm64 常量(0 与 -1)并结合 movi 指令使用。

移动立即数指令的第三种形式,fmov,允许你将某些浮点常量加载到向量寄存器的各个通道中。允许的语法如下:

fmov V`n`.2S, #`fimm`
fmov V`n`.4S, #`fimm`
fmov V`n`.2D, #`fimm`

浮点立即数常量(fimm)必须是由以下方式定义的值

±n ÷ 16 × 2r

其中 16 ≤ n ≤ 31 且 –3 ≤ r ≤ 4。你不能通过此公式表示 0.0;如果需要将 0.0 加载到向量寄存器的各个通道中,只需使用 movi 指令将整数常量 0 加载到这些通道(所有位为 0 即为 0.0):

fmov v0.2s, #1.0  // Loads [0.0, 0.0, 1.0, 1.0] into V0
fmov v0.2d, #2.0  // Loads [2.0, 2.0] into V0

移动立即数指令只将某些常量值加载到向量寄存器中。以下是你可以作为立即数浮点常量加载的确切值(Gas 只接受这些值):

 0.1250000  0.1328125   0.1406250   0.1484375
 0.1562500  0.1640625   0.1718750   0.1796875
 0.1875000  0.1953125   0.2031250   0.2109375
 0.2187500  0.2265625   0.2343750   0.2421875
 0.2500000  0.2656250   0.2812500   0.2968750
 0.3125000  0.3281250   0.3437500   0.3593750
 0.3750000  0.3906250   0.4062500   0.4218750
 0.4375000  0.4531250   0.4687500   0.4843750
 0.5000000  0.5312500   0.5625000   0.5937500
 0.6250000  0.6562500   0.6875000   0.7187500
 0.7500000  0.7812500   0.8125000   0.8437500
 0.8750000  0.9062500   0.9375000   0.9687500
 1.00       1.0625      1.125       1.1875
 1.25       1.3125      1.375       1.4375
 1.50       1.5625      1.625       1.6875
 1.75       1.8125      1.875       1.9375
 2.00       2.1250      2.250       2.3750
 2.50       2.6250      2.750       2.8750
 3.00       3.1250      3.250       3.3750
 3.50       3.6250      3.750       3.8750
 4.00       4.2500      4.500       4.7500
 5.00       5.2500      5.500       5.7500
 6.00       6.2500      6.500       6.7500
 7.00       7.2500      7.500       7.7500
 8.0        8.5         9.0         9.5
10.0       10.5        11.0        11.5
12.0       12.5        13.0        13.5
14.0       14.5        15.0        15.5
16.0       17.0        18.0        19.0
20.0       21.0        22.0        23.0
24.0       25.0        26.0        27.0
28.0       29.0        30.0        31.0

基于程序通常使用 Neon 寄存器的方式,这是一个合理的值集合,可以编码成 32 位指令操作码。要加载更大或不同的常量,请参见下一页第 11.3.4 节,“向量加载与存储”。

11.3.3 寄存器或通道值复制

dup 指令允许你将保存在通用寄存器或向量寄存器单一通道中的值复制到向量寄存器的所有通道中。此指令支持以下形式:

dup V`n`.2D, X`m`        // Copy X`m` into lanes 0-1 (64 bits each) in V`n`.

dup V`n`.8B, W`m`        // LO 8 bits of W`m` to lanes 0-7 in V`n`
dup V`n`.16B, W`m`       // LO 8 bits of W`m` to lanes 0-15 in V`n`

dup V`n`.4H, W`m`        // LO 16 bits of W`m` to lanes 0-3 in V`n`
dup V`n`.8H, W`m`        // LO 16 bits of W`m` to lanes 0-7 in V`n`

dup V`n`.2S, W`m`        // W`m` to lanes 0-1 in V`n`
dup V`n`.4S, W`m`        // W`m` to lanes 0-3 in V`n`

dup V`n`.8B, V`m`.B[`i1`]  // Dup V`m` lane `i1` through lanes 0-7 in V`n`.
dup V`n`.16B, V`m`.B[`i2`] // Dup V`m` lane `i2` through lanes 0-15 in V`n`.

dup V`n`.4H, V`m`.H[`i3`]  // Dup V`m` lane `i3` through lanes 0-3 in V`n`.
dup V`n`.8H, V`m`.H[`i4`]  // Dup V`m` lane `i4` through lanes 0-7 in V`n`.

dup V`n`.2S, V`m`.S[`i5`]  // Dup V`m` lane `i5` through lanes 0-1 in V`n`.
dup V`n`.4S, V`m`.S[`i6`]  // Dup V`m` lane `i6` through lanes 0-3 in V`n`.

dup V`n`.2D, V`m`.D[`i7`]  // Dup V`m` lane `i7` through lanes 0-1 in V`n`.

每对指令中的第一条指令仅复制 Vn 的 LO 64 位数据;每对指令中的第二条指令复制完整的 128 位数据。这两条单独的指令复制 128 位数据。

11.3.4 向量加载与存储

mov、movi、mvni、fmov 和 dup 指令可以在向量寄存器之间以及在通用寄存器和向量寄存器之间移动数据,并且可以将常量加载到向量寄存器中。然而,它们不允许你从内存加载寄存器或将向量寄存器中的值存储到内存中。Neon 指令集提供了几条加载和存储指令来处理这些任务。

因为加载和存储指令是最基础的,所以本节首先讨论它们。要加载或存储整个 128 位的向量寄存器,请使用以下语法

ldr Q`n`, `memory`
str Q`n`, `memory`

其中内存是常见的 ARM 内存寻址模式之一(与标量 ldr 和 str 指令相同)。注意使用 Qn 来表示寄存器(而非 Vn)。这是 Qn 寄存器合法使用的少数几个地方之一(让人想知道为什么不直接使用 Vn)。这些指令将加载或存储完整的 16 字节,即 128 位。

stp 指令还允许使用向量寄存器(Qn)操作数:

ldp Q`n`, Q`m`, `memory`
stp Q`n`, Q`m`, `memory`

请注意,这些指令中的 n 和 m 不必是连续的数字,而可以是 0 到 31 范围内的任意值。

11.3.5 交错加载与存储

Neon 指令集提供了加载和存储指令,将数据加载到多个向量寄存器的单个通道中。这些指令从内存加载交错的数据到一个、两个、三个或四个向量寄存器中。加载(ld1、ld2、ld3 和 ld4)和存储(st1、st2、st3 和 st4)指令分别支持非交错数据、交错数据对、交错数据三元组和交错数据四元组。以下小节将描述这些类型的交错加载和存储指令。

11.3.5.1 交错加载和存储寻址模式

交错的加载和存储指令访问内存,但它们不支持 ARM 内存寻址模式的完整集合,仅支持三种

`instr`  {`register_list`}, [X`n`]
`instr`  {`register_list`}, [X`n`], X`m`
`instr`  {`register_list`}, [X`n`], #`imm`

其中,instr 是 ldn/stn 之一,register_list 是一个以逗号分隔的 Qn 寄存器集合,加载和存储指令将在从内存加载数据或向内存存储数据时使用这些寄存器。(以下章节将更详细地讨论寄存器 _list。)

标准的寄存器间接寻址模式是[Xn]。ldn/stn 指令将访问存储在通用寄存器 Xn 中的内存地址的数据。

[Xn], Xm 寻址模式将有效地址计算为 Xn 和 Xm 中值的和。这是一个后增量寻址模式;在访问指定的内存地址后,立即将 Xm 的值加到 Xn 中。

[Xn], #imm 寻址模式也是一种后增量寻址模式,它将有效地址计算为 Xn + imm 的和,然后在引用地址后将立即数加到 Xn 中。立即数的值限制为常数 1、2、4、8、16、32、48 或 64,register_list 操作数决定了你必须使用的值。以下章节将描述每个版本指令的允许立即常数。

11.3.5.2 ld1/st1

ld1 指令从顺序(非交错)内存位置加载一到四个寄存器的数据。使用单一向量寄存器时,该指令的语法如下

ld1 {V`n.`8B}, `memory`
ld1 {V`n.`16B}, `memory`
ld1 {V`n.`B}[`index`], `memory`

ld1 {V`n.`4H}, `memory`
ld1 {V`n.`8H}, `memory`
ld1 {V`n.`H}[`index`], `memory`

ld1 {V`n.`2S}, `memory`
ld1 {V`n.`4S}, `memory`
ld1 {V`n.`S}[`index`], `memory`

ld1 {V`n.`2D}, `memory`
ld1 {V`n.`D}[`index`], `memory`

其中,memory 是以下之一:

[Xn]

[Xn], Xm

[Xn], #imm

如果存在 imm 操作数,它必须与寄存器操作数的大小匹配。也就是说,对于 B 必须是 1;对于 8B,必须是 8;对于 16B,必须是 16;对于 H,必须是 2;依此类推。

使用 {Vn.8B} 寄存器列表操作数的 ld1 指令将 8 字节数据加载到 Vn 的低 64 位中,而 {Vn.16B} 寄存器列表操作数则加载 16 字节。

使用 4H 或 2S 类型说明时,ld1 寄存器还会将 64 位(四个 hword 或两个 word)加载到 Vn 的 LO 64 位中。使用 8H 或 4S 类型时,ld1 指令将 128 位数据加载到 Vn 中。虽然 8B、4H 和 2S 类型与 16B、8H、4S 和 2D 类型看起来可以互换(它们将相同量的数据加载到 Vn 中),但你应该尽量选择最适合你操作数据的类型。这样不仅能提高文档的质量,还有助于 ARM CPU 的内部微架构根据你使用的数据类型更好地优化其操作。

使用裸 B、H、S 或 D 类型说明时,ld1 指令从内存中加载 Vn 中的单个 lane 数据。此操作不会影响 Vn 中其他 lanes 的数据。这是 ld1 指令最重要的变种,因为它允许你一次从内存的不同位置将数据逐步构建到向量寄存器中的每个 lane。

为什么 ld1 指令需要在向量寄存器规格说明周围加上大括号?该指令的目标操作数实际上是一个寄存器列表。你可以在这个列表中指定一个到四个寄存器,如下所示的示例:

ld1 {v1.8b}, [x0]
ld1 {v1.8b, v2.8b}, [x0]
ld1 {v1.8b, v2.8b, v3.8b}, [x0]
ld1 {v1.8b, v2.8b, v3.8b, v4.8b}, [x0]

该列表中可以出现的寄存器有两个限制:

  • 它们必须是连续编号的寄存器(V0 紧随 V31 之后)。

  • 列表中所有寄存器的类型说明必须相同。

如果列表中有两个或更多连续编号的寄存器,你可以使用简写形式:

{V`n`.`t` - V(`n` + `m`).t}

其中 m 是 1、2 或 3,t 是常见的向量类型之一,如下所示的示例:

ld1 {v1.8b}, [x0]
ld1 {v1.8b - v2.8b}, [x0]
ld1 {v1.8b - v3.8b}, [x0]
ld1 {v1.8b - v4.8b}, [x0]

当你在列表中指定多个寄存器时,ld1 指令将从连续的位置加载值到寄存器中。例如,以下代码将从 X0 持有的地址加载 V0,加载 16 字节的数据;从 X0 + 16 加载 V1,加载 16 字节的数据;从 X0 + 32 加载 V2,加载 16 字节的数据:

ld1 {v0.16b, v1.16b, v2.16b}, [x0]

st1 指令支持相同的指令语法(当然,你需要将 st1 助记符替换为 ld1)。它将寄存器中的内容或寄存器中的某些 lane 数据存储到指定的内存位置。下面是一个示例,演示了将 V0 和 V1 的值存储到由 X0 指定的位置:

st1 {v0.16b, v1.16b}, [x0]

该指令将 V0 中的值存储到 X0 所持有的地址,并将 V1 中的值存储到地址 X0 + 16。

11.3.5.3 ld2/st2

ld2 和 st2 指令用于加载和存储交错数据。这两个指令使用以下语法:

ld2 {V`n`.`t1`, V`(n + 1)`.`t1`}, `memory`
ld2 {V`n`.`t2`, V`(n + 1)`.`t2`}[`index`], `memory`
st2 {V`n`.`t1`, V`(n + 1)`.`t1`}, `memory`
st2 {V`n`.`t2`, V`(n + 1)`.`t2`}[`index`], `memory`

其中寄存器列表必须包含恰好两个寄存器,并且它们的寄存器编号必须是连续的。t1 大小为 8B、16B、4H、8H、2S、4S 或 2D,而 t2 则是 B、H、S 或 D。字面常数索引是该类型大小的合适 lane 编号(B 为 0 到 15,H 为 0 到 7,S 为 0 到 3,D 为 0 到 1)。最后,内存是第 633 页中描述的寻址模式之一,详见第 11.3.5.1 节,“交错加载和存储寻址模式”。

带索引的变种(加载一个通道到两个寄存器)将从指定的内存地址加载第一个寄存器的通道,并在 n 字节后加载第二个寄存器的通道(其中 n 是通道的大小,以字节为单位)。

带有 t1 类型规格的 ld2 指令(例如 8B、16B、4H、8H 等),同时一次加载两个寄存器的一个值(指定类型:B、H、S 或 D),并交替将目标通道分配给两个寄存器。例如

ld2 {v0.8b, v1.8b}, [x0]

从内存位置 X0、X0 + 2、X0 + 4、X0 + 6、X0 + 8、X0 + 10、X0 + 12 和 X0 + 14 加载 V0 的 LO 8 字节。它从位置 X0 + 1、X0 + 3、X0 + 5、X0 + 7、X0 + 9、X0 + 11、X0 + 13 和 X0 + 15 加载 V1 的 LO 8 字节。这样就解交错了内存中的数据,将偶数字节加载到 V0 中,将奇数字节加载到 V1 中。图 11-4 显示了 ld2 如何从 X0 提取交错数据,并将解交错的结果存储到 V0 和 V1 中。

图 11-4:ld2 解交错操作

如果你指定了半字类型(4H 或 8H),则 ld2 指令将对 16 位值进行解交错(偶数和奇数半字)。这对于解交错左右声道的数字音频轨道(每个样本 16 位)特别有用。

如果你指定了 2S/4S 或 2D,这个指令将解交错字或双字。例如,如果你有一个浮点复数数组,ld2 指令可以解交错实部和虚部。

因为 ld2 解交错的是成对的对象,寄存器列表必须恰好包含两个寄存器。汇编器会拒绝任何其他数量的寄存器列表。

st2 指令使用相同的语法(当然是将 st2 替换为 ld2)。该指令将指定类型的两个寄存器中的数据通道存储到内存中,交错存储这两个寄存器之间的数据。存储操作基本上是反转了图 11-4 中的箭头(即将数据从 V0 和 V1 复制到 X0 中,交错这两个数据集)。

11.3.5.4 ld3/st3

ld3 和 st3 指令的行为类似于 ld2/st2,不同之处在于它们(解)交错内存中的三个对象,而不是两个,并且寄存器列表必须包含恰好三个寄存器。

使用 ld3/st3 指令的常见示例是将由 3 字节组成的红、绿、蓝(RGB)值进行(解)交错——其中红色、绿色和蓝色值各占 8 位——存储在内存中。使用 ld3 指令,你可以将由 3 字节 RGB 值组成的数组解交错成独立的红色、绿色和蓝色字节数组。你可以使用 st3 指令将红色、绿色和蓝色值交错成 RGB 数组。

11.3.5.5 ld4/st4

最后,正如你现在可能已经猜到的那样,ld4 指令将从内存中复制四个连续的值,并将这些值存储到由四元素寄存器列表指定的四个寄存器的同一通道中:

ld4 {v4.d, v5.d, v6.d, v7.d}[0], [x0]

该指令将从 X0 地址开始的四个 dword 复制到 V4、V5、V6 和 V7 的第 0 条通道中。图 11-5 展示了 ld4 指令的操作方式。

图 11-5:ld4 指令操作

ld4/st4 指令对于(解)交织由四个对象组成的内存数据非常有用。例如,假设你有一个内存中的 CMYK(青色-品红色-黄色-黑色)颜色像素数组,如图 11-6 所示。

图 11-6:内存中的 CMYK 像素布局

当将图像提交给打印服务时,通常需要提供颜色分离——即四个独立的图像,分别只包含青色像素、品红色像素、黄色像素和黑色像素。因此,你需要从全彩图像中提取所有青色像素,并为其创建一个单独的图像;品红色、黄色和黑色像素也同样需要处理。

你可以使用 ld4 指令从原始图像中提取青色、品红色、黄色和黑色的值,并将这些像素放置到四个独立的向量寄存器中。例如,假设 X0 指向内存中第一个 CMYK 像素(32 位)

ld4 {v0.b - v3.b}[0], [x0]

将提取 X0 指向的 4 个字节,并将它们分配到 V0(青色)、V1(品红色)、V2(黄色)和 V3(黑色)的第 0 条通道。如果你将 X0 加 4 并重复这条指令,指定通道 1 而不是通道 0,这将把第二个像素分离到 V0–V4 的第 1 条通道中。再重复 14 次,你将得到 16 个青色像素存储在 V0 中,16 个品红色像素存储在 V1 中,16 个黄色像素存储在 V2 中,16 个黑色像素存储在 V3 中。然后,你可以将这四个寄存器存储到图像区域中,用于保存四种颜色的分离图像。对所有四色图像中的像素重复这个过程,就可以得到你需要的颜色分离。

当然,你可以使用 8B 和 16B 类型来同时处理 8 个或 16 个像素:

ld4 {v0.16b - v3.16b}, [x0]

该指令将 64 字节复制到 V0、V1、V2 和 V3,每四个字节分别进入四个寄存器的连续通道:V0 接收偏移量i % 4 的字节,V1 接收偏移量(i % 4) + 1 的字节,以此类推,其中i是内存中的字节索引。

11.3.5.6 ldnr

ld1、ld2、ld3 和 ld4 指令将一个到四个寄存器的通道加载到内存中连续的值,解交织一个交织的对象数组(字节、半字、字或 dword)。而 ld1r、ld2r、ld3r 和 ld4r 指令也解交织一个交织的对象,但内存对象是一个单一对象,指令会将其复制到所有的向量寄存器通道中。

这些指令的语法与 ldn 指令相同,只是在助记符上增加了 r 后缀:

ld1r {V`n`.`t`}, `memory`
ld2r {V`n`.`t,` V`(n + 1)`.`t`}, `memory`
ld3r {V`n`.`t,` V`(n + 1)`.`t,` V`(n + 2)`.`t`}, `memory`
ld4r {V`n`.`t,` V`(n + 1)`.`t,` V`(n + 2)`.`t,` V`(n + 3)`.`t`}, `memory`

.t 表示通道类型(稍后会详细说明),内存则是常规的 ldn 寻址模式。你也可以使用范围语法

V`n`.`t -` V(`n` + `m`).`t`

当在列表中指定两个或更多寄存器时。

对于这些指令,允许的类型有 8B、16B、4H、8H、2S、4S 和 2D。当与 ld1r 指令一起使用时,这些类型规格执行以下操作:

  • 8B 将内存中找到的字节复制到 Vn 的前 8 条道中,并在每条道中复制该字节。

  • 16B 将内存中找到的字节复制到 Vn 的所有 16 条道中,并在每条道中复制该字节。

  • 4H 将内存中找到的 hword 复制到 Vn 的前 4 条道中。

  • 8H 将内存中找到的 hword 复制到 Vn 的所有 8 条道中。

  • 2S 将内存中找到的 word 复制到 Vn 的前 2 条道中。

  • 4S 将内存中找到的 word 复制到 Vn 的所有 4 条道中。

  • 2D 将内存中找到的 dword 复制到 Vn 的 2 条 dword 道中。

ld1r 指令从内存中只获取单个道的值,并将其写入目标寄存器的所有道。ld2r 指令从连续的内存位置获取两个道对象,并将第一个值复制到第一个寄存器的所有道中,第二个值复制到第二个寄存器的所有道中。ld3r 指令从内存中获取三个道对象,并将它们分别复制到第一个、第二个和第三个寄存器中。最后,ld4r 指令从内存中获取四个道对象,并将它们用于初始化四个寄存器的道。

11.3.6 寄存器交错与解交错

ldn/stn 和 ldnr 指令在内存和向量寄存器之间操作。当你需要交错和解交错出现在向量寄存器中的数据,并将结果保留在向量寄存器中时,可以使用 trn1、trn2、zip1、zip2、uzip1、uzip2 和 ext 指令。

11.3.6.1 trn1 和 trn2

trn1 和 trn2(转置)指令——之所以称之为转置,是因为你可以使用它们转置一个 2 × 2 矩阵(或者稍加努力处理更大的数组)中的元素——从两个源寄存器中提取数据,并将这些数据交错到目标寄存器中。这些指令使用以下语法:

trn1 V`d`.`t`, V`a`.`t`, V`b`.`t`
trn2 V`d`.`t`, V`a`.`t`, V`b`.`t`

其中 t 可以是 8B、16B、4H、8H、2S、4S 或 2D。d(目标寄存器)、a 和 b 是寄存器编号,范围为 0 到 31。这些寄存器编号是任意的(它们不必是连续的值,正如 ldn/stn 和 ldnr 指令的情况)。

trn1 指令将 Va.t 中偶数编号道的数据复制到 Vd.t 中对应的道,并将 Vb.t 中偶数编号道的数据复制到 Vd.t 中的奇数道,同时忽略 Va.t 和 Vb.t 中的奇数编号道。例如,考虑以下指令:

trn1 v0.4s, v2.4s, v4.4s

此指令交错 V2 和 V4 中的交替字节,将结果保留在 V0 中,如 图 11-7 所示。

图 11-7:trn1 v0.4s、v2.4s、v4.4s 操作

trn2 指令将 Va.t 和 Vb.t 中奇数编号道的值复制到 Vd.t 中交替的道中,如 图 11-8 所示(与 trn1 类似,只是交换了源位置)。

图 11-8:trn2 v0.4s, v2.4s, v4.4s 操作

考虑如 图 11-9 所示的 V2 和 V3 中存储的 2×2 双精度矩阵(注意数组元素的位置,与通常的期望不同)。

图 11-9:V2 和 V3 中存储的 2×2 矩阵

以下两个指令将转置此矩阵,并将结果保留在 V0 和 V1 中:

trn1 v0.2d, v2.2d, v3.2d
trn2 v1.2d, v2.2d, v3.2d

当然,trn1 和 trn2 通常用于重新排列和交错向量寄存器中的值,即使你不是在转置 2×2 矩阵。

11.3.6.2 zip1 和 zip2

zip1 和 zip2 指令与 trn1 和 trn2 类似,都是从两个源寄存器中获取数据并生成交错结果。zip 这个名字来源于 zipper(拉链):指令就像拉链一样将两边的元素交错在一起。除了助记符外,其语法与 trn1 和 trn2 完全相同。

zip1 V`d`.`t`, V`a`.`t`, V`b`.`t`
zip2 V`d`.`t`, V`a`.`t`, V`b`.`t`

其中 t 可以是 8B、16B、4H、8H、2S、4S 或 2D(指令中的所有类型必须相同)。

zipn 和 trnn 指令在选择源通道进行交错时有所不同。zip1 指令从源寄存器的开始部分交错通道值(消耗了每个源寄存器的一半通道并忽略剩余的通道)。参见 图 11-10 了解示例。

图 11-10:zip1 v0.4s, v1.4s, v2.4s 操作

zip2 指令的工作方式类似,只不过它处理源寄存器中通道的后半部分。图 11-11 显示了一个示例。

图 11-11:zip2 v0.4s, v1.4s, v2.4s 操作

从这些图中可以看出,zip1 和 zip2 指令通常用于仅使用寄存器创建交错数据。

11.3.6.3 uzp1 和 uzp2

uzp1 和 uzp2(unzip1 和 unzip2)指令是 zip1 和 zip2 的逆操作。它们从两个源寄存器中取出交错数据,并在目标寄存器中生成解交错的数据。它们的语法与 trnn 和 zipn 指令相同:

uzp1 V`d`.`t`, V`a`.`t`, V`b`.`t`
uzp2 V`d`.`t`, V`a`.`t`, V`b`.`t`

如常,t 可以是 8B、16B、4H、8H、2S、4S 或 2D。

uzp1 指令将 Va.t 中的偶数通道复制到 Vd.t 的前半部分,然后将 Vb.t 中的偶数通道附加到 Vd.t 的末尾。参见 图 11-12 了解示例。

图 11-12:uzp1 v0.4s, v1.4s, v2.4s 操作

uzp2 指令从源寄存器中复制奇数通道。图 11-13 显示了 uzp2 指令的一个示例。

图 11-13:uzp2 v0.4s, v1.4s, v2.4s 操作

如果类型说明符是 64 位(8B、4H 或 2S),则 uzp1 和 uzp2 指令会在目标寄存器的高位通道中填充 0。

11.3.6.4 ext

ext(提取)指令通过从一个向量中提取 n 字节和从第二个向量中提取 8-n(或 16-n)字节,创建一个 8 字节或 16 字节的向量。此指令允许您从两个向量中提取一个 8 字节或 16 字节的向量。此指令的语法如下

ext V`d`.8B, V`s`1.8B, V`s`2.8B, #`n`
ext V`d`.16B, V`s`1.16B, V`s`2.16B, #`n`

其中 n 是起始索引,Vd 是目标寄存器,Vs1 和 Vs2 是源寄存器。

ext Vd.8B, Vs1.8B, Vs2.8B, #n 指令从 Vs2 中提取 LO n 字节并将其复制到 Vd 中 LO 64 位的 HO n 字节中 它还从 Vs1 中提取 LO 8-n 字节并将其复制到 Vd 的 LO 8-n 字节中。有关 ext 的示例,请参见 图 11-14。

图 11-14:ext v0.8B, v1.8B, v2.8B, #2 指令

ext Vd.16B, Vs1.16B, Vs2.16B, #n 指令从 Vs2 中提取 LO n 字节并将其复制到 Vd 的 HO n 字节中 它还从 Vs1 中提取 LO 16-n 字节并将其复制到 Vd 的 LO 16-n 字节中(有关示例,请参见 图 11-15)。

图 11-15:ext v0.16B, v1.16B, v2.16B, #5 指令

此指令仅支持 8B 和 16B 类型。通过选择合适的索引值(n),您可以轻松提取 hwords、words 或 dwords,该索引值包含您要提取的所有对象。

11.3.7 使用 tbl 和 tbx 进行表查找

tbl 和 tbx(表查找)指令允许您用查找表中最多包含 64 个条目的值交换一个寄存器中的所有字节值。这些指令的语法如下

tbl V`d`.8B, {`table_list`}, V`s`.8B
tbl V`d`.16B, {`table_list`}, V`s`.16B
tbx V`d`.8B, {`table_list`}, V`s`.8B
tbl V`d`.16B, {`table_list`}, V`s`.16B

其中 table_list 是一个包含一个至四个(连续编号)寄存器的列表,这些寄存器都必须附加 16B 类型。(您也可以使用 Vn.t - Vm.t 语法,其中 m > n 且 m < (n + 4)。)这个寄存器列表提供了一个查找表,包含 16、32、48 或 64 个条目。第一个寄存器的 LO 字节是表中的索引 0;最后一个寄存器的 HO 字节是表中的索引 15、31、47 或 63。

tbl 指令从源寄存器(Vs.t)中提取每个字节,并将其值用作查找表的索引。它从表中提取该索引处的字节,并将其复制到目标寄存器中对应的位置——即与提取源字节相同的字节索引;因此,这相当于 Vd[i] = table[Vs[i]]。如果值超出范围(大于 15、31、47 或 63,具体取决于表的大小),tbl 指令会在目标寄存器的相应位置存储 0。tbx 指令的工作方式与 tbl 类似,不同之处在于如果源值超出范围,它会保持目标位置不变。

对于非常小的表(64 个条目或更少),你可以使用 tbl 和 tbx 来实现查找表,如第十章所述。然而,这两个指令的主要目的是提供任意向量排列,例如 trn1/trn2、zip1/zip2、uzp1/uzp2 和 ext 指令。例如,假设你想反转向量寄存器中所有 16 个字节的位置(交换索引 0 和 15,1 和 14,2 和 13,3 和 12,以此类推)。图 11-16 显示了一个 16 字节字节序交换操作,双向箭头指向字节交换的两个位置。

图 11-16:16 字节字节序交换

如果你加载一个向量寄存器,包含以下 16 字节值

0x000102030405060708090a0b0c0d0e0f

然后在源寄存器中使用该值作为 tbl(或 tbx)指令,tbl(或 tbx)将在作为 table_list 提供的单个 16 字节寄存器中交换字节,并将交换后的字节存储到目标寄存器中。假设你已经将该值加载到 V0,以下指令将交换{V1}中的字节,将结果放入 V2:

tbl v2.16b, {v1.16b}, v0.16b

在你将 V1 加载为待交换的字节并执行此指令后,V2 将包含交换后的值。

要将 tbl 或 tbx 用作向量排列指令,请将排列索引加载到源寄存器(此示例中为 V0)。索引的值始终在 0 到 15 的范围内,用于选择 table_list 中的特定条目。对于真正的排列,源寄存器中的每个值(0 到 15)将恰好出现一次,并且表 _list 中始终只有一个寄存器。因为你将源寄存器中的值限制在 0 到 15 的范围内,所以表索引值总是有效的,因此可以使用 tbl 或 tbx。只要值不超出范围,两者的功能是完全相同的。

当然,你可以通过在源寄存器中指定不同的值,使用你喜欢的任何排列方式。与 ext 指令一样,tbl 和 tbx 仅支持 8B 和 16B 通道类型。然而,通过选择源寄存器通道值的位置来排列半字、字和双字,合成其他类型(至少对于排列来说)是相当容易的。显然,对于表查找操作(而不是排列),你仅限于使用 8 位值,因此半字、字和双字类型没有任何意义。

11.3.8 使用 rev16、rev32 和 rev64 进行字节序交换

rev16、rev32 和 rev64 指令类似于它们的标量对应指令 rev16、rev32 和 rev(参见第 3.3 节,“小端和大端数据组织”,第 133 页),当然,它们操作的是向量源寄存器中的通道,而不是通用整数寄存器。它们的语法如下:

rev16 V`d.t1`, V`s.t1`  // Swap the bytes in the half-word lanes.
rev32 V`d.t2`, V`s.``t2`  // Swap the bytes in the word lanes.
rev64 V`d.t1`, V`s.t3`  // Swap the bytes in the double-word lanes.

这些指令的合法类型和通道数量见表 11-1。

表 11-1:rev*指令的合法类型和通道数量

t 类型和通道数量
t1 8B, 16B
t2 8B, 16B, 4H, 或 8H
t3 8B, 16B, 4H, 8H, 2S, 或 4S

如果通道数和类型是 8B、4H 或 2S,指令仅在源寄存器的低 64 位上操作(并清除目标寄存器的高 64 位)。如果通道数和类型是 16B、8H 或 4S,这些指令将在源寄存器的 128 位全范围内进行操作。

11.4 垂直和水平操作

迄今为止,矢量操作是垂直的,意味着它们在多个寄存器中的同一通道上进行操作(在大多数图示中,当寄存器按堆叠方式排列时,显示了垂直的操作方向)。考虑以下矢量加法指令:

add v0.16b, v1.16b, v2.16b

至于标量加法操作(例如,add w0, w1, w2),该指令将两个源寄存器(V1.16B 和 V2.16B)的值相加,产生一个结果存储在目标寄存器中。然而,这并不是一次 128 位的加法操作,而是一个 8 位操作重复执行 16 次。矢量操作通常是逐通道进行的,并行执行多个小的操作。对于此特定指令,CPU 将 16 个字节值相加,产生 16 个独立的字节结果。这就是 SIMD 编程的神奇之处:通过一条指令做 16 倍的工作(因此它的执行速度大约比逐个执行这 16 个字节加法快 16 倍)。

图 11-17 展示了加法指令的逐通道操作,逐通道加法按照箭头方向进行。

图 11-17:逐通道操作

逐通道操作彼此独立,这意味着如果发生进位、溢出或其他异常情况,这些异常仅限于单个通道的范围。由于只有一组 NVZC 条件码标志,矢量指令无法(也不会)影响这些标志。如果发生无符号进位(例如,在字节通道中执行 255 + 1 时),求和结果会自动回绕,并且没有溢出或下溢的标识。通常,处理溢出的方法与处理标量运算时的方式完全不同。本章在后续章节讨论饱和操作时,会介绍一些处理溢出的方法。

某些矢量指令提供水平操作,也称为归约操作。与其在两个寄存器之间逐通道操作,这些操作是在单个矢量寄存器内的所有通道上进行操作,产生一个标量结果。例如,addv 指令会产生一个单一矢量寄存器中所有通道的总和。

11.5 SIMD 逻辑操作

由于逻辑(布尔)运算是按位计算的,矢量逻辑运算具有独特之处,你可以利用它们执行 128 个独立的位操作。无论你将源操作数视为 16 个 1 字节的值,还是作为 1 个 128 字节的值,结果是相同的。因此,矢量逻辑运算仅支持两种类型:8B(用于 64 位操作数)和 16B(用于 128 位操作数)。如果你真的想对 4H 或 2S 操作数进行运算,只需指定 8B;你会得到相同的结果。同样,对于 8H、4S 或 2D 操作数,指定 16B 也会得到相同的结果。

Neon 指令集支持八种逻辑指令,如表 11-2 所示。这里,t 是 8B 或 16B,Vd 是目标寄存器,Vs1 是左侧源寄存器,Vs2 是右侧源寄存器(对于 not 指令,Vs 是唯一的源寄存器)。

表 11-2: Neon 逻辑指令

记忆法 语法 描述
and and Vd.t, Vs1.t, Vs2.t Vd = Vs1 & Vs2
orr orr Vd.t, Vs1.t, Vs2.t Vd = Vs1 | Vs2
orn orn Vd.t, Vs1.t, Vs2.t Vd = Vs1 | ~(Vs2)
eor eor Vd.t, Vs1.t, Vs2.t Vd = Vs1 ^ Vs2
bic bic Vd.t, Vs1.t, Vs2.t Vd = Vs1 & ~(Vs2)(位清除)
bif bif Vd.t, Vs1.t, Vs2.t 如果为假则插入位
bit bit Vd.t, Vs1.t, Vs2.t 如果为真则插入位
bsl bsl Vd.t, Vs1.t, Vs2.t 按位选择
not not Vd.t, Vs.t Vd = ~Vs

and、orr 和 eor 指令执行常规逻辑运算(与标量相同),无需进一步解释。orn 指令与 bic 类似,因其在进行 OR 运算之前会反转第二个源操作数。

bic(位清除)指令在 Vs1 的值中清除所有在 Vs2 中为 1 的位置的位,并将结果存储到 Vd 中。注意,不需要 bis(位设置)指令,因为 orr 会在 Vd 中设置位。

bif(如果为假则插入位)和 bit(如果为真则插入位)指令之所以不同寻常,是因为它们在计算过程中使用了三个操作数(而不是使用两个输入的函数,并将结果存储到第三个操作数中)。bif 指令会在 Vs2 中相应位为 0 的位置,将 Vs1 中的位复制到 Vd 中。在 Vs2 中为 1 的位置,此指令会保留 Vd 中对应位的不变。bit 指令的工作方式类似,除了它会在 Vs2 中相应位为 1 时复制位(而不是 0)。

bsl(按位选择)指令根据 Vd 的原始内容,从 Vs1 或 Vs2 中选择位并将其复制到 Vd 中。如果 Vd 在某个位位置上原本为 1,则 bsl 会从 Vs1 中选择对应的位。否则,它会从 Vs2 中选择该位。

not 指令反转源寄存器中的所有位,并将结果存储到目标寄存器中。此指令与其他逻辑指令不同,只有一个源操作数。

Neon 指令集支持一些 orr 和 bic 指令的特殊立即数版本

orr V`d.t`, #`imm`
orr V`d.t`, #`imm,` lsl  #`shift`
bic V`d.t`, #`imm`
bic V`d.t`, #`imm,` lsl #`shift`

其中 imm 是一个无符号 8 位立即数;类型(t)是 2S、4S、4H 或 8H;移位(shift)为 0 或 8(如果 t 是 4H/8H),或者 0、8、16 或 24(如果 t 是 2S 或 4S)。如果没有指定移位,则默认为 0。这些指令需要 H 和 S 类型而非 B 类型,因为它们在 Vd.t 中通过各通道的字节复制立即数值。### 11.6 SIMD 移位操作

移位指令通常被认为是逻辑运算。然而,从向量的角度来看,它们更准确地应该被视为算术运算,因为移位操作可能会产生溢出。向量移位操作通过四种方式处理溢出:

  • 忽略移位操作的进位(截断)

  • 饱和移位结果

  • 对结果进行四舍五入

  • 提供扩展的移位操作,其目标操作数大于源寄存器

本节描述了这些不同的移位操作。

注意

Neon 指令集使用基于 shr shl 的助记符来表示左移和右移。这与标量整数指令集使用的 lsl lsr* 和* asr* 指令有所不同。我想不出为什么他们要这么做;如果他们保持一致的命名规则,指令集会更容易学习。*

11.6.1 左移指令

shl 指令将向量寄存器的每个通道按指定的位数向左移位。该指令会将 0 填充到(空出的)LO 位中。任何从通道的 HO 位溢出的进位都会丢失。语法如下:

shl V`d`.8B, V`s`.8B, #`imm`
shl V`d`.16B, V`s`.16B, #`imm`
shl V`d`.4H, V`s`.4H, #`imm`
shl V`d`.8H, V`s`.8H, #`imm`
shl V`d`.2S, V`s`.2S, #`imm`
shl V`d`.4S, V`s`.4S, #`imm`
shl V`d`.2D, V`s`.2D, #`imm`

其中 Vd 是目标寄存器,Vs 是源寄存器。立即数值必须在表 11-3 中列出的范围内(取决于指定的类型)。如果立即数移位值超出这些范围,汇编器将报告错误。

表 11-3:有效的 shl 移位值

类型 移位范围
8B/16B 0 到 7
4H/8H 0 到 15
2S/4S 0 到 31
2D 0 到 63

还有一个标量 shl 指令,它作用于向量寄存器的 LO dword,语法如下:

shl D`d`, D`s`, #`imm`

其中 Dd 是目标标量寄存器,Ds 是源寄存器(对应 Vd 和 Vs 的 LO 64 位)。imm 移位计数必须在 0 到 63 的范围内。请注意,该指令会将 Dd 的 HO 64 位清零。

要按可变的位数移位各通道,请参见第 11.6.9 节,“按可变位数移位”,在第 657 页。

11.6.2 饱和左移

饱和左移指令 uqshl、sqshl 和 sqshlu 将向量中的各通道向左移位指定的位数。如果发生溢出(无论是有符号还是无符号),这些指令会根据指令的类型将结果饱和到最大的(有符号或无符号)值。此类指令的语法如下:

uqshl  V`d.t`, V`s.t`, #`imm`
uqshl  V`d.t`, V`s.t`, V`c.t`
sqshl  V`d.t`, V`s.t`, #`imm`
sqshl  V`d.t`, V`s.t`, V`c.t`
sqshlu V`d.t`, V`s.t`, #`imm`
sqshlu V`d.t`, V`s.t`, V`c.t`

其中 Vd 是目标寄存器,Vs 是源寄存器,imm 是一个合适的立即移位常数,或者 Vc 在 LO 字节中包含移位计数,t 是 8B、16B、4H、8H、2S、4S 或 2D 类型。t 的规范必须与 Vd 和 Vs 相同。

移位值的范围取决于通道类型;请参见前一节中的表 11-3 以查看合法的立即数值。对于立即数值,如果移位常数超出范围,汇编器会报告错误。对于寄存器移位计数变体,如果 LO 字节包含超出范围的值,则当某个通道包含非零值时,指令将始终饱和结果(请参见后面关于饱和的讨论)。uqshl 指令将值左移一位,将结果存储在目标寄存器的相应通道中。如果 HO 位被设置(在移位之前),该指令将在目标通道中存储所有 1 位(最大无符号值)。例如,如果 V1 中的某个通道包含 0x7F,则在执行以下操作后,相应的通道将包含 0xFE(0x7F 左移一位):

uqshl v0.16b, v1.16b, #1

然而,如果源通道包含 0x80 到 0xFF 之间的值,则将其左移一位会在目标通道中产生 0xFF。通常,如果任何非 0 位被从源通道移出,相应的目标通道将包含 0xFF。

sqshl 指令是一个有符号饱和左移操作。对于有符号值,如果一个通道的高两位包含不同的值,左移时会发生溢出。对于负源值(HO 位被设置),溢出将饱和为一个结果,其中 HO 位被设置,所有其他位为 0(例如,对于半字类型,0xa000 将饱和为 0x8000)。

sqshlu 指令类似于 sqshl,不同之处在于它将目标视为无符号值。正值(包括 0)源值将像 uqshl 指令一样向左移位,而负源值(HO 位被设置)将饱和为 0。

还有 uqshl、sqshl 和 sqshlu 指令的标量版本。

uqshl  R`d`, R`s`, #`imm`
sqshl  R`d`, R`s`, #`imm`
sqshlu R`d`, R`s`, #`imm`

其中 Rn(n = d 或 s)是 Bn、Hn、Sn或 Dn中的一个寄存器,d、s 和 imm 具有通常的含义和限制。与普通的 shl 指令不同,这些指令支持字节、半字和字寄存器,以及双字寄存器。

对于向量指令,uqshl 指令执行无符号饱和。如果任何位被移出源寄存器的 HO 位,这些指令将目标(Bn、Hn、Sn或 Dn)设置为所有 1 位。这些指令将结果通过包含 Rd的其余向量寄存器进行零扩展。

sqshl 指令执行有符号饱和,结果保存在目标(标量)寄存器中。该指令将相应向量寄存器的剩余 HO 位清零(即所有超出标量寄存器大小的 HO 位)。

sqshlu 指令对有符号源值执行移位操作,但将其饱和为无符号值(负值结果饱和为 0,类似于此指令的向量寄存器版本)。

11.6.3 左移长整数

左移长整数 指令 sshll、sshll2、ushll 和 ushll2 提供了一种处理移位操作溢出的方法。这些指令将通道中的值符号扩展或零扩展到两倍大小,然后对扩展后的源值进行左移,并将结果存储到(双倍大小的)目标通道中。以下是这些指令的语法:

ushll  V`d.t2`, V`s`.`t`, #`imm`
sshll  V`d.t2`, V`s`.`t`, #`imm`

其中 t2 是双倍大小的类型,可以是 8H、4S 或 2D;t 是原始类型,可以是 8B、4H 或 2S。imm 是移位计数,应该在 0 到 n – 1 的范围内,其中 n 是 t 类型中的位数。

ushll 指令将源通道中的值零扩展到两倍大小,对零扩展后的结果进行指定的位移,然后将结果存储到相应的(双倍大小的)目标通道中。sshll 指令将源通道值符号扩展到两倍大小,然后对结果进行移位,并将结果存储到双倍大小的目标通道中。

由于这些指令将其值的大小加倍,因此它们仅在源寄存器的低 64 位上操作(字节的通道 0 到 7,半字的通道 0 到 3,字的通道 0 到 1)。这些指令忽略源寄存器的高 64 位。

为了处理源寄存器的高 64 位,ARM 提供了 ushll2 和 sshll2 指令:

ushll2 V`d.t4`, V`s.t3`, #`imm`
sshll2 V`d.t4`, V`s.t3`, #`imm`

这些指令实现的操作与 ushll 和 sshll 指令相同,不同之处在于它们从高 64 位(HO 64 bits)而不是低 64 位(LO 64 bits)中获取源操作数。为了表示这一点,t4/t3 类型对必须是 8H/16B、4S/8H 或 2D/4S。imm 位移值必须与源通道的位大小匹配(对于 8H/16B 为 0 到 15,对于 4S/8H 为 0 到 31,对于 2D/4S 为 0 到 63)。

ushll、ushll2、sshll 和 sshll2 指令没有标量版本。如果需要此操作,只需使用向量版本,并在必要时将高 64 位清零。

11.6.4 移位与插入

sli 和 sri 指令允许你将源操作数按指定的位数进行移位,然后(使用其他指令)将其他位插入移位操作腾出的(0 位)位置。以下是这些指令的语法:

sli V`d.t`, V`s.t`, #`imm`
sri V`d.t`, V`s.t`, #`imm`

其中 t 是常见的类型集合:8B、16B、4H、8H、2S、4S 或 2D。对于 sli 指令,imm 是位移计数,必须在 0 到 n – 1 的范围内,其中 n 是一个通道的位数。对于 sri 指令,立即数是一个计数值,范围为 1 到 n。

sli 指令将 Vs.t 中的每个通道按指定的位数向左移位。然后,它将 Vd.t 的 n - imm 低位(LO bits)通过逻辑或运算与结果合并(替换被移位进来的 0),并将结果存回 Vd.t,如 图 11-18 所示。

图 11-18:sli 指令操作

例如,要移入 1 位而不是 0 位,你可以先将目标寄存器加载为全 1 位,然后执行 sli 指令,如以下代码所示:

movi    v0.16b, #0xff
movi    v1.4s, #0x1
sli     v0.4s, v1.4s, #4

这会在 V0 中生成 0x0000001f0000001f0000001f0000001f。

sri 指令将 Vs.t 中每个通道右移指定的位数,然后将 n - imm 高位的 Vd.t 与结果进行逻辑或运算(替换被移入的 0),然后将结果存回 Vd.t,如图 11-19 所示。

图 11-19:sri 指令操作

sli 和 sri 指令的标量版本具有以下语法:

sli D`d`, D`s`, #`imm`  // `imm` = 0 to 63
sri D`d`, D`s`, #`imm`  // `imm` = 1 to 64

这些指令作用于指定向量寄存器(Dn)的低 64 位,并将目标寄存器的高 64 位清零。

11.6.5 带符号和无符号右移

由于算术左移和逻辑左移本质上是相同的操作,ARM 使用单一指令来执行这两种操作:shl。然而,右移的逻辑和算术移位有所不同。因此,Neon 指令集提供了两条指令,sshr 和 ushr,分别用于带符号和无符号的右移(即算术右移和逻辑右移)。

如第二章所述,左移操作相当于乘以 2,右移操作大致等同于除以 2。我说是大致相同,因为带符号和无符号数的行为有所不同。例如,当你将值 1 右移一个位置时,结果是 0。如果你将带符号值–1(全 1 位)使用算术右移操作右移,结果则是–1。在一种情况下,移位是向 0 舍入,而在另一种情况下则是远离 0 舍入。两种情况都没有特别正确或错误,但无法选择舍入方向可能会成为问题。

使用标量指令时,你可以通过在移位后将进位标志添加到结果中来逆转这种舍入效果:

asr  x0, x0, #1
adc  x0, x0, xzr // -1 -> 0 and 1 -> 1

由于向量操作不会跟踪移位中的进位标志,因此你无法选择纠正这一点。Neon 指令集因此提供了舍入移位指令 srshr 和 urshr,这些指令会为你添加进位。

Neon 右移指令的语法如下所示:

ushr  V`d`.`t`, V`s`.`t`, #`imm`  // Unsigned (logical) shift right
urshr V`d`.`t`, V`s`.`t`, #`imm`  // Unsigned rounding shift right
sshr  V`d`.`t`, V`s`.`t`, #`imm`  // Signed (arithmetic) shift right
srshr V`d`.`t`, V`s`.`t`, #`imm`  // Signed rounding shift right

向量寄存器允许的类型是常见的 8B、16B、4H、8H、2S、4S 或 2D。舍入变体(在助记符中第二个字符为 r)会在移位操作后将进位标志添加回目标通道。

sshr、srshr、ushr 和 urshr 指令也有标量版本:

sshr  D`d`, D`s`, #`imm`
srshr D`d`, D`s`, #`imm`
ushr  D`d`, D`s`, #`imm`
urshr D`d`, D`s`, #`imm`

这些指令操作在由 Dd(目标)和 Ds(源)指定的向量寄存器的低 64 位上。imm 移位操作数必须在 1 到 64 的范围内。它们会将相应 Vd 寄存器的高 64 位置为零。除此之外,它们与其向量组件是相同的。

11.6.6 累积右移

累积右移指令具有以下语法:

usra  V`d.t`, V`s.t`, #`imm`
ursra V`d.t`, V`s.t`, #`imm`
ssra  V`d.t`, V`s.t`, #`imm`
srsra V`d.t`, V`s.t`, #`imm`

这些指令与右移指令基本相同,但它们将移位后的值添加到相应的目标通道中(而不是仅仅存储移位后的值)。

11.6.7 缩小右移

shrn, shrn2, rshrn 和 rshrn2 指令提供了与 shll 和 shll2 指令相反的操作。与将操作数大小翻倍的移位操作不同,它们会将大小减半(“缩小”)。这些指令的语法如下:

shrn   V`d.t1`, V`s.t2`, #`imm`
shrn2  V`d.t3`, V`s.t4`, #`imm`
rshrn  V`d.t1`, V`s.t2`, #`imm`
rshrn2 V`d.t3`, V`s.t4`, #`imm`

其中:

t1 是 8B, 4H 或 2S

t2 是 8H, 4S 或 2D

t3 是 8B, 16B, 4H, 8H, 2S 或 4S

t4 是 8H, 4S 或 2D

shrn 指令将每个通道右移指定数量的位(从左侧移入 0);提取低 8、16 或 32 位(具体取决于 t1 的大小);并将结果存储到目标寄存器中相同通道编号的位置。shrn 指令会忽略(截断)任何无法适应目标通道的高位(回忆一下,目标通道大小是源通道的一半)。该指令会将目标寄存器的高 64 位置为零。

shrn2 指令执行完全相同的操作,但将结果存储到高 64 位。

rshrn 和 rshrn2 指令与 shrn 和 shrn2 指令做的是一样的操作,但在缩小之前会对移位结果进行四舍五入。rshrn 指令还会清除目标寄存器的上半部分。

由于缩小右移指令会丢弃除了适合目标通道的低位之外的所有高位,你可能会认为需要一组单独的指令来提取右移操作后的高位。但实际上并不需要这样的指令;只需在 shrn, shrn2, rshurn 或 rshrn2 的移位计数中添加 8、16 或 32 即可提取高位。

11.6.8 饱和右移与缩小

标准的缩小右移指令在将结果缩小到源通道大小的一半时,会截断任何高位(HO)位。饱和右移指令在移位后的值如果无法适应目标通道时,会将其饱和。有关这些指令的语法,请参考 表 11-4。

表 11-4: 按通道饱和右移与缩小指令

助记符 语法 描述
uqshrn uqshrn Vd.t1, Vs.t2, #imm 无符号右移 imm 位并进行缩小。将数据存储到 Vd 的低 64 位。
uqrshrn uqrshrn Vd.t1, Vs.t2, #imm 无符号右移 imm 位并进行缩小和四舍五入。将数据存储到 Vd 的低 64 位。
sqshrn sqshrn Vd.t1, Vs.t2, #imm 按 imm 位数有符号右移并进行缩小处理。将数据存储到 Vd 的低 64 位。
sqrshrn sqrshrn Vd.t1, Vs.t2, #imm 按 imm 位数有符号右移,并进行缩小和四舍五入处理。将数据存储到 Vd 的低 64 位。
sqshrun sqshrun Vd.t1, Vs.t2, #imm 按 imm 位数有符号右移,并进行缩小和饱和处理为无符号数。将数据存储到 Vd 的低 64 位。
sqrshrun sqrshrun Vd.t1, Vs.t2, #imm 按 imm 位数有符号右移,并进行缩小、四舍五入和饱和处理为无符号数。将数据存储到 Vd 的低 64 位。
uqshrn2 uqshrn2 Vd.t3, Vs.t4, #imm 按 imm 位数无符号右移并进行缩小处理。将数据存储到 Vd 的高 64 位。
uqrshrn2 uqrshrn2 Vd.t3, Vs.t4, #imm 按 imm 位数无符号右移并进行缩小和四舍五入处理。将数据存储到 Vd 的高 64 位。
sqshrn2 sqshrn2 Vd.t3, Vs.t4, #imm 按 imm 位数有符号右移并进行缩小处理。将数据存储到 Vd 的高 64 位。
sqrshrn2 sqrshrn2 Vd.t3, Vs.t4, #imm 按 imm 位数有符号右移,并进行缩小和四舍五入处理。将数据存储到 Vd 的高 64 位。
sqshrun2 sqshrun2 Vd.t3, Vs.t4, #imm 按 imm 位数有符号右移,并进行缩小和饱和处理为无符号数。将数据存储到 Vd 的高 64 位。
sqrshrun2 sqrshrun2 Vd.t3, Vs.t4, #imm 按 imm 位数有符号右移,并进行缩小、四舍五入和饱和处理为无符号数。将数据存储到 Vd 的高 64 位。

表 11-5 列出了 表 11-4 中出现的饱和右移指令的合法类型和通道数。

表 11-5:饱和右移类型和通道数

t 法律类型和通道数
t1/t2 8B/8H, 4H/4S, 或 2S/2D
t3/t4 16B/8H, 8H/4S, 或 4S/2D

带有 2 后缀的指令将其缩小的结果存储到目标寄存器的高 64 位。没有此后缀的指令将清零目标寄存器的高 64 位。

uqrshrn、sqrshrn、uqrshrn2 和 sqrshrn2 指令在对结果进行饱和之前,会先对移位结果进行四舍五入(如果需要进行饱和)。四舍五入是通过将源通道移出的最后一位加回到值中来实现的。

带有 s 前缀的指令对有符号值进行操作,而带有 u 前缀的指令则对无符号值进行操作。无符号值在目标通道大小无法容纳时会饱和为全 1 位,而有符号值会饱和为高位 1 和其他位 0,或者高位 0 和所有其他位 1。

sqrshrun 和 sqrshrun2 指令执行以下操作:

  • 执行按指定位数的算术右移操作

  • 通过将最后移出的位加回结果来对结果进行四舍五入

  • 如果结果无法容纳在目标通道内,则将结果饱和为最大无符号值(全 1 位);负值则饱和为 0。

  • 将饱和结果存储到目标通道中

sqrshrun 指令将结果存储到目标寄存器的低 64 位中;sqrshrun2 将结果存储到目标寄存器的高 64 位中。

这些指令也有标量版本:

sqshrn B`d`, H`s`, #`imm`
sqshrn H`d`, S`s`, #`imm`
sqshrn S`d`, D`s`, #`imm`

uqshrn B`d`, H`s`, #`imm`
uqshrn H`d`, S`s`, #`imm`
uqshrn S`d`, D`s`, #`imm`

sqrshrn B`d`, H`s`, #`imm`
sqrshrn H`d`, S`s`, #`imm`
sqrshrn S`d`, D`s`, #`imm`

uqrshrn B`d`, H`s`, #`imm`
uqrshrn H`d`, S`s`, #`imm`
uqrshrn S`d`, D`s`, #`imm`

sqshrun B`d`, H`s`, #`imm`
sqshrun H`d`, S`s`, #`imm`
sqshrun S`d`, D`s`, #`imm`

sqrshrun B`d`, H`s`, #`imm`
sqrshrun H`d`, S`s`, #`imm`
sqrshrun S`d`, D`s`, #`imm`

请注意,这些指令会清除基础向量寄存器中超出指定标量寄存器的上位位。

11.6.9 按变量数量的位数移位

要按变量数量的位数移位某个通道,使用以下其中之一指令:

sshl   V`d.t`, V`s.t`, V`c.t`
ushl   V`d.t`, V`s.t`, V`c.t`
sqshl  V`d.t`, V`s.t`, V`c.t`
uqshl  V`d.t`, V`s.t`, V`c.t`
srshl  V`d.t`, V`s.t`, V`c.t`
urshl  V`d.t`, V`s.t`, V`c.t`
sqrshl V`d.t`, V`s.t`, V`c.t`
uqrshl V`d.t`, V`s.t`, V`c.t`

其中 t 是常见的 8B、16B、4H、8H、2S、4S 或 2D。

Vc.t 保持有符号的移位计数值在低字节中。对于正值(范围 0 到 0x7F),指令将一个通道的位左移相应数量的位。对于负值(0xFF 到 0x80;–1 到 –128),尽管使用了 shl 助记符,指令还是会将位右移。有关通过使用寄存器指定移位计数时的合法范围,请参见表 11-6。

表 11-6: 合法的 Vc.t 移位范围

类型 无符号(SHL) 有符号(SHR)
8B/16B 0 到 7 –1 到 –7
4H/8H 0 到 15 –1 到 –15
2S/4S 0 到 31 –1 到 –31
2D 0 到 63 –1 到 –63

超出表 11-6 中列出的范围的值将产生表 11-7 中显示的结果。

表 11-7: 当计数超过允许范围时的移位结果

移位指令 正数计数,正溢出 正数计数,负溢出 负数计数,正值 负数计数,负值
sshl 0 0 0 –1(全 1 位)
ushl 0 0 0 0
sqshl 高位位 0,其它位 1(例如,0x7F) 高位位 1,其它位 0(例如,0x80) 0 –1(全 1 位)
uqshl 全 1 位(例如,0xff) 全 1 位(例如,0xff) 0 0
srshl 0 0 0 0(–1 + 进位)
urshl 0 0 0 1(0 + 进位)
sqshl 高位位 0,其它位 1(例如,0x7F) 高位位 1,其它位 0(例如,0x80) 0 –1(全 1 位)
uqshl –1(全 1 位) –1(全 1 位) 0 0
sqrshl 高位位 0,其它位 1(例如,0x7F) 高位位 1,其它位 0(例如,0x80) 0 0(–1 + 进位)
uqrshl –1(全 1 位) –1(全 1 位) 0 1(0 + 进位)

在这些指令中使用 shf(用于移位)可能比 shl 更合适,因为该名称更符合操作。只需记住,Vc.t 中低字节的值是有符号整数,负值表示右移。

Neon 的 shl 指令也有一些标量饱和版本

sqshl    R`d`, R`s`, R`c`
uqshl    R`d`, R`s`, R`c`
sqrshl   R`d`, R`s`, R`c`
uqrshl   R`d`, R`s`, R`c`

其中 R 代表标量寄存器名称之一(B、H、S 或 D)。这些指令将标量寄存器 Rs 中的值按 Rc 的 LO 字节指定的位数进行移位,并将移位结果存储在 Rd 中。Rc 被视为一个有符号数;正值将 Rs 左移,而负值将 Rs 右移。如果在移位过程中发生溢出(有符号或无符号,根据情况),这些指令将 Rd 设置为最大正有符号或无符号值。

如果 sqshl 指令的移位计数为负,CPU 将执行算术右移操作,右移时将复制 HO 位。正值(以及 0)源值将饱和为 0,负值源值将饱和为 -1(所有 1 位)。

sqrshl 和 uqrshl 指令是饱和移位指令的特殊舍入版本。在右移操作期间(即 Rc 为负时),这些指令通过在最后一个移出的位是 1 时加 1 来舍入结果。

11.7 SIMD 算术操作

Neon 指令集包括几个常见的算术操作,包括加法、减法和乘法。唯一的例外是没有除法操作;相反,你必须计算倒数并用该值相乘(使用提供的指令来估算倒数)。

11.7.1 SIMD 加法

Neon 提供了一套广泛的指令,用于加法通道(忽略溢出)、加法并饱和(当发生溢出时)或执行水平加法。

11.7.1.1 向量加法

Neon 指令集提供了几种可以在向量寄存器中的通道内加法整数和浮点值的指令,如表 11-8 所列。这些指令计算 Vd = Vl + Vr,其中 Vd 是目标寄存器,Vl 是左操作数,Vr 是右操作数。

表 11-8: Neon 加法指令

指令助记符 语法 描述
add add Vd.t1, Vl.t1, Vr.t1 逐通道计算整数和
fadd fadd Vd.t2, Vl.t2, Vr.t2 逐通道计算浮点数和
sqadd sqadd Vd.t1, Vl.t1, Vr.t1 逐通道计算带饱和的有符号整数和
uqadd uqadd Vd.t1, Vl.t1, Vr.t1 逐通道计算无符号整数和,带饱和
saddl saddl Vd.t3, Vl.t4, Vr.t4 逐通道计算带长扩展的有符号整数和
uaddl uaddl Vd.t3, Vl.t4, Vr.t4 逐通道计算带长扩展的无符号整数和
saddl2 saddl2 Vd.t5, Vl.t6, Vr.t6 逐通道计算带长扩展的有符号整数和
uaddl2 uaddl2 Vd.t5, Vl.t6, Vr.t6 逐通道计算带长扩展的无符号整数和
saddw saddw Vd.t3, Vl.t3, Vr.t4 逐通道计算带宽扩展的有符号整数和
uaddw uaddw Vd.t3, Vl.t3, Vr.t4 逐通道计算带宽扩展的无符号整数和
saddw2 saddw2 Vd.t5, Vl.t6, Vr.t6 执行逐道有符号整数和,带有宽扩展
uaddw2 uaddw2 Vd.t5, Vl.t6, Vr.t6 执行逐道无符号整数和,带有宽扩展
addhn addhn Vd.t4, Vl.t3, Vr.t3 执行逐道加法并进行缩小
raddhn raddhn Vd.t4, Vl.t3, Vr.t3 执行逐道加法,带四舍五入和缩小
addhn2 addhn2 Vd.t6, Vl.t5, Vr.t5 执行逐道加法并进行缩小(使用高位)
raddhn2 raddhn2 Vd.t6, Vl.t5, Vr.t5 执行逐道加法,带有四舍五入和缩小(使用高位)
shadd shadd Vd.t7, Vl.t7, Vr.t7 执行逐道有符号加法,带缩小
uhadd uhadd Vd.t7, Vl.t7, Vr.t7 执行逐道无符号加法,并进行缩小
srhadd srhadd Vd.t7, Vl.t7, Vr.t7 执行逐道有符号加法,带四舍五入和缩小
urhadd urhadd Vd.t7, Vl.t7, Vr.t7 执行逐道无符号加法,并进行四舍五入和缩小
addp addp Vd.t1, Vl.t1, Vr.t1 执行向量逐对相加
faddp faddp Vd.t2, Vl.t2, Vr.t2 执行向量浮点数逐对相加
saddlp saddlp Vd.t8, Vl.t9 执行向量逐对相加,有符号长整数
uaddlp uaddlp Vd.t8, Vl.t9 执行向量逐对相加,无符号长整数
saddalp saddalp Vd.t8, Vl.t9 执行向量逐对相加并累加,有符号长整数
uaddalp uaddalp Vd.t8, Vl.t9 执行向量逐对相加并累加,无符号长整数

表 11-9 列出了加法和减法指令的合法类型。

表 11-9:向量加法和减法的合法类型

t 合法类型
t1 8B, 16B, 4H, 8H, 2S, 4S, 或 2D
t2 2S, 4S, 或 2D
t3/t4 8H/8B, 4S/4H, 或 2D/2S
t5/t6 8H/16B, 4S/8H, 或 2D/4S
t7 8B, 16B, 4H, 8H, 2S, 或 4S
t8/t9 4H/8B, 8H/16B, 2S/4H, 4S/8H, 1D/2S, 或 2D/4S

本节其余部分更详细地描述了表 11-8 中的每个加法指令。

add 指令使用向量寄存器操作数执行逐道加法。任何溢出(有符号或无符号)都被忽略,和的结果保留结果的低位。如果类型为 8B、4H 或 2S,add 指令仅加上寄存器低 64 位的道,并将目标寄存器的高 64 位清零。图 11-20 展示了 16B 逐道加法的示例。

图 11-20:使用 add Vd.16b, Vs1.16b, Vs2.16b 进行 16B 逐道加法

fadd 指令使用向量寄存器操作数,将两个或四个道的单精度值相加,或一对双精度浮点值相加。使用 2S 类型时,fadd 指令会清除目标寄存器的高 64 位。

sqadd 和 uqadd 指令逐通道进行加法(分别为符号和无符号加法),但是在溢出(或者在加法时出现符号数下溢)情况下会饱和结果。与加法指令类似,接受 64 位源操作数的指令会生成 64 位结果,并将目标寄存器的高 64 位清零。

saddl 和 uaddl 指令获取源寄存器低 64 位中的通道,将这些值符号扩展或零扩展为其大小的两倍,计算和,并将结果存储到完整的目标寄存器中(具有双倍大小的通道)。目标寄存器类型必须指定为源寄存器类型的两倍大小(见 图 11-21)。因为两个 n 位数的和最多需要 n + 1 位,这些指令将生成正确的结果,不会发生溢出或下溢。

图 11-21:一个 uaddl 操作(uaddl Vd.4s, Vs1.4h, Vs2.4h)

saddl2 和 uaddl2 指令同样对源寄存器中一半通道的值进行符号扩展或零扩展,并在目标寄存器的完整 128 位中生成和。然而,saddl2 和 uaddl2 指令计算的是源寄存器中高 64 位的通道和(见 图 11-22)。

图 11-22:一个 saddl2 操作(saddl2 Vd.4s, Vs1.8h, Vs2.8h)

尽管 saddl2 和 uaddl2 指令的源操作数只有 64 位,但你必须指定 128 位类型(16B、8H、4S)作为源类型,因为该指令从 128 位值的高 64 位中提取数据。

saddw、uaddw、saddw2 和 uaddw2 指令允许你生成两个操作数大小不同的和。saddw 和 uaddw 指令期望第二个源操作数的类型是第一个源操作数和目标操作数类型的一半,尽管你为所有三个操作数指定了相同数量的通道。这些指令将分别对第二个源操作数的通道进行符号扩展或零扩展,以适应其他两个操作数的大小,计算和,并将数据存储到目标通道中(见 图 11-23)。

图 11-23:一个 uaddw 操作(uaddw Vd.4s, Vs1.4s, Vs2.4h)

saddw2 和 uaddw2 指令同样对第二个源操作数进行符号扩展或零扩展,但它们操作的是高 64 位而不是低 64 位(见 图 11-24)。你必须为第二个操作数指定双倍的通道数量,这样指令才能操作第二个源操作数的完整 128 位。

图 11-24:一个 saddw2 操作(saddw2 Vd.4s, Vs1.4s, Vs2.4h)

使用 saddw、uaddw、saddw2 和 uaddw2 指令时可能会发生溢出(下溢),例如当将 0xFFFF 和 0x01 相加时。这些指令会忽略溢出并保留结果的低位(LO)位。

addhn(向量加法并缩小)和 raddhn(向量加法、舍入并缩小)指令将指定的通道相加,然后通过仅保留高位(HO)位来缩小结果。这些指令的目标类型是源类型的一半大小。例如,如果将半字通道相加,缩小的结果只会保留高位字节(HO)。

raddhn 指令在将结果存储到目标寄存器之前进行舍入。如果结果的低半部分的高位(HO 位)为 1,raddhn 会将高字节(HO byte)加 1;否则,它返回与 addhn 相同的结果。考虑以下指令:

raddhn  v0.8b, v1.8h, v2.8h

如果 V2 包含 0x00010001,V1 包含 0xFE7FFE7F,那么在执行此指令后,V0 将包含 0xFFFF。如果 V1 包含 0xFE7EFE7E,V0 则会包含 0xFEFE。

执行 addhn 和 raddhn 时仍然可能发生溢出。将半字 0xFFFF 和 0x0001 相加将会在相应的目标字节通道中产生 0x00。

addhn2 和 raddhn2 指令也会进行加法和缩小(如果指定,则进行舍入);然而,它们将结果存储在目标寄存器的高 64 位(HO 64 位)中,并且保持目标寄存器的低 64 位(LO 64 位)不变。由于这些指令操作的是目标寄存器的高 64 位,因此目标的通道数必须是源寄存器的两倍。例如:

addhn2  v0.16b, v1.8h, v2.8h

将 V1 和 V2 的低 8 个半字相加,并将每个通道加法的高 8 位结果存储到 V0 的高 8 字节中(保持低 8 字节不变)。你必须将目标寄存器的类型指定为 16B,即使此指令仅将 8 字节存储到寄存器中。

shadd、uhadd、srhadd 和 urhadd 指令将一对通道相加,右移 1 位(对于包含 r 的指令,可以选择舍入),并将结果存储到目标通道中。像往常一样,以 s 开头的指令处理有符号值,而以 u 开头的指令处理无符号值。由于n 位的加法永远不会产生超过n + 1 位的结果,而除以 2 等同于右移 1 位,这些指令永远不会导致溢出。考虑两个最大的单字节值相加,0xFF + 0xFF = 0x1FE。将此和右移 1 位后得到 0xFF,它正好适合 8 位。即使舍入,也不会发生溢出。

这些指令特别适用于处理数字音频。例如,假设你想要混合两个 16 位音频轨道。仅仅对两个轨道的半字进行求和,会使音量提高 3 分贝(dB)(相当于将数字值翻倍)。在求和后将结果减半,能够将音量提升降低 3 dB。urhadd 指令非常适合混合这些轨道,因为它会将结果除以 2,从而对两个轨道的值进行平均。

11.7.1.2 配对加法

到目前为止,所有加法操作都在源操作数中对应的通道上进行,生成的结果会存储在目标寄存器的相同通道中。这被称为纵向加法,因为数据从一个寄存器流向另一个寄存器,呈垂直流动,如之前在图 11-20 中所示。偶尔,你可能想要对向量中的相邻元素求和,而不是对两个向量中对应通道的元素进行求和(横向加法)。你可以通过表 11-8 中的配对加法指令来实现这一点。

配对加法指令,顾名思义,是在向量中加相邻的通道对。由于结果需要的通道数是源通道数的一半,因此配对加法会从两个源寄存器中生成一个单一的向量结果。考虑以下示例,它对 V1 和 V2 中的半字进行配对加法,生成 V0 中的配对和:

addp  v0.4s, v2.4s, v1.4s

该指令计算以下结果:

V0[0] = V2[0] + V2[1]

V0[1] = V2[2] + V2[3]

V0[2] = V1[0] + V1[1]

V0[3] = V1[2] + V1[3]

图 11-25 展示了这个操作。

图 11-25:addp v0.4s, v2.4s, v1.4s 指令

该指令还有一个浮点版本,用于在一对向量中相邻的单精度或双精度值之间进行加法运算:faddp。例如,以下指令执行与之前的 addp 整数示例相同的操作,但添加的是相邻的单精度浮点值,而不是 32 位整数值:

faddp  v0.4s, v1.4s, v2.4s

addp 指令在加法过程中会忽略任何溢出。为了获得正确的结果,可以使用 saddlp 和 uaddlp 指令(有符号和无符号配对加法长操作)在加法前对数据通道值进行符号扩展或零扩展。这两个指令的语法与其他加法指令不同:它们只有两个寄存器操作数(一个源寄存器和一个目标寄存器)。例如,以下指令将结果的大小加倍,存入目标操作数,同时对源操作数的相邻元素进行求和,因此不需要第二个寄存器操作数:

saddlp  v0.2d, v1.4s

请注意,目标寄存器的类型大小必须是源寄存器的两倍,并且通道数必须是源寄存器的一半。

uaddalp 和 saddalp 指令在功能上类似于 uaddlp 和 saddlp,但它们不是简单地将成对的和存储到目标道中,而是将和加到目标道中已有的值上。

11.7.1.3 向量饱和累加

Neon 指令集包括两条指令,将源向量的道相加并将结果存入目标向量的相应道中。这些指令是

usqadd  V`d`.t, V`s`.t  // Add lanes of V`s` to V`d`.
suqadd  V`d`.t, V`s`.t  // Add lanes of V`s` to V`d`.

其中 t 为 8B、4H 或 2S,当操作寄存器的低 64 位时,或 16B、8H、4S 或 2D,当操作所有 128 位时。64 位变体会清除 Vd 的高 64 位。

这些指令的特别之处在于,它们允许将(饱和后)一个无符号输入值添加到一个有符号值中,或者将一个有符号数值添加到一个无符号值中(通常指令只对一种数据类型操作)。

usqadd 指令将源道中的有符号值添加到相应目标道中的无符号值。如果和超过了目标道大小的最大无符号值,该指令会将道饱和到最大值。如果和变为负值,该指令会将目标道饱和为 0。例如,如果一个半字目标道包含 0xFFF0,而相应源道包含 0xFF,usqadd 指令(使用 4H 或 8H 类型)将使目标道的结果为 0xFFFF。另一方面,如果目标道包含 0x08,源道包含 0xFFF0(–16),那么它们的和将使目标道的结果为 0。

suqadd 指令是相反操作:它将一个无符号源操作数加到一个有符号目标操作数中,饱和到最大的有符号值。例如,如果目标半字道包含 0x7FF0,而相应的源道包含 0x00FF,它们的和将产生 0x7FFF,最大的有符号值。注意,如果目标操作数包含 0xFFFF(–1),而源操作数为 0x0002,你将得到 0x0001 作为目标道的结果(–1 + 2 = 1)。

usqadd 和 suqadd 指令也有标量变体

usqadd  `Rd`, `Rs`   // Add `Rs` to `Rd`.
suqadd  `Rd`, `Rs`   // Add `Rs` to `Rd`.

其中 Rd 和 Rs 是标量寄存器 Bn、Hn、Sn 或 Dn 中的一个。

suqadd 指令在溢出时始终会产生最大的有符号值,因为你无法通过将一个无符号数加到其上来减少该值。

11.7.1.4 水平加法

addv(跨向量加法)指令计算单个源向量寄存器中所有道的和,并将结果存入另一个向量寄存器的标量元素中(这称为 归约)。该指令的语法如下

addv `Rd`, V`s.t`

其中 R 是目标寄存器,且是 Bn、Hn 或 Sn 中的一个。合法的向量寄存器类型和道数取决于标量寄存器;表 11-10 列出了有效的类型。

表 11-10:addv 指令的有效向量寄存器类型

标量寄存器 (Rd) 有效道数和类型
Bd 8B 或 16B
Hd 4H 或 8H
Sd 4S

此指令对于对数组元素求和非常有用。不幸的是,目标标量类型必须与源数据 lane 的类型相同,并且任何溢出都会被忽略。没有指令可以将和结果零扩展或符号扩展为双倍大小的结果。因此,如果可能发生溢出,建议在执行 addv 之前,将向量元素零扩展或符号扩展为下一个更大的大小。可以使用 saddlp 或 uaddlp 指令来添加相邻的两个元素并分别进行符号或零扩展,然后使用 addv 指令对得到的双倍大小的 lane 进行求和。

addvl 指令是 ARM 可扩展向量扩展(SVE)的一部分,超出了本书的范围。虽然你可能会认为 addvl 是 addv 指令的长版本,但实际上它的功能完全不同。详细信息请参阅 ARM SVE 文档。

11.7.1.5 标量饱和加法和标量逐对加法

Neon 指令集还提供了几条饱和标量加法指令。

sqadd  `Rd`, `Rs`1, `Rs`2
uqadd  `Rd`, `Rs`1, `Rs`2

其中 R 代表标量寄存器名称之一 B、H、S 或 D。这些指令对指定 V 寄存器的低位中的 8 位、16 位、32 位或 64 位有符号或无符号整数值进行操作(请参见图 11-2,了解 Vn、Bn、Hn、Sn 和 Dn 寄存器之间的对应关系)。这些指令的功能与其向量版本相同,唯一不同的是,它们只对标量值进行操作,而不是对每个 lane 执行向量操作。

以下是 addp 和 faddp 指令的标量变体:

addp   D`d`, V`s`.2D
faddp  S`d`, V`s`.2S
faddp  D`d`, V`s`.2D

请注意这些指令的 lane 数量和类型支持的限制。

addp 指令忽略(丢弃)源向量中两个双字元素相加时的溢出。addpl 和 addpl2 指令没有标量版本。如果需要该指令的扩展精度版本,请使用实际的 addpl 和 addpl2 指令(与第二个包含零的向量一起使用)。

11.7.2 减法

尽管 Neons 指令集中用于减法的指令数量不如加法指令多,但大多数加法指令都具有相应的减法补充指令。表 11-11 提供了各种向量减法指令及相关数据类型的语法;这些指令通常计算 Vd = Vl Vr(除非有特别说明),其中 Vd = 目标寄存器,Vl = 左操作数,Vr = 右操作数。

表 11-11:Neon 减法指令

指令助记符 语法 描述
sub sub Vd.t1, Vl.t1, Vr.t1 执行按 lane 逐位整数差值
fsub fsub Vd.t2, Vl.t2, Vr.t2 执行按 lane 逐位浮点数差值
uqsub uqsub Vd.t1, Vl.t1, Vr.t1 执行按 lane 逐位无符号整数减法并带饱和处理
sqsub sqsub Vd.t1, Vl.t1, Vr.t1 执行按 lane 逐位有符号整数减法并带饱和处理
usubl usubl Vd.t3, Vl.t4, Vr.t4 计算按位无符号长整型减法
ssubl ssubl Vd.t3, Vl.t4, Vr.t4 计算按位签名长整型减法
usubl2 usubl2 Vd.t5, Vl.t6, Vr.t6 计算 Vr 上半部分的按位无符号长整型减法
ssubl2 ssubl2 Vd.t5, Vl.t6, Vr.t6 计算 Vr 上半部分的按位签名长整型减法
usubw usubw Vd.t3, Vl.t3, Vr.t4 计算按位无符号宽整型减法
ssubw ssubw Vd.t3, Vl.t3, Vr.t4 计算按位签名宽整型减法
usubw2 usubw2 Vd.t5, Vl.t4, Vr.t6 计算涉及 Vl 上半部分的按位无符号宽整型减法
ssubw2 ssubw2 Vd.t5, Vl.t5, Vr.t6 计算涉及 Vl 上半部分的按位签名宽整型减法
subhn subhn Vd.t4, Vl.t3, Vr.t3 计算按位减法并进行缩小
rsubhn rsubhn Vd.t4, Vl.t3, Vr.t3 计算按位减法并进行舍入和缩小
subhn2 subhn2 Vd.t6, Vl.t5, Vr.t5 计算按位减法并进行缩小(使用高位位元)
rsubhn2 rsubhn2 Vd.t6, Vl.t5, Vr.t5 计算按位减法并进行舍入和缩小(使用高位位元)
uhsub uhsub Vd.t7, Vl.t7, Vr.t7 计算按位无符号减法并进行二分
shsub shsub Vd.t7, Vl.t7, Vr.t7 计算按位签名减法并进行二分

这些指令的行为与它们的加法对应指令非常相似,当然,区别在于它们是减去通道中的值,而不是加上这些值。详情请见前一节。

还有一个饱和标量减法指令:

sqsub  R`d`, R`s`1, R`s`2
uqsub  R`d`, R`s`1, R`s`2

这将两个源标量寄存器(Bn、Hn、Sn 或 Dn)相减,产生一个标量结果。

11.7.3 绝对差

除了常规的减法指令,Neon 指令集还包括几条指令,用于计算对应通道中值的差,并计算该差值的绝对值。这些指令非常方便用于计算距离和其他向量(如物理学中的向量)计算。

表 11-12 列出了可用的绝对差指令。在语法列中,Vd = 目标寄存器,Vl = 左操作数,Vr = 右操作数。每条指令通常计算 Vd = abs(Vl Vr),除非另有说明。

表 11-12:Neon 绝对差指令

指令助记符 语法 描述
uabd uabd Vd.t1, Vl.t1, Vr.t1 向量无符号绝对差;各通道包含无符号值。
sabd sabd Vd.t1, Vl.t1, Vr.t1 向量签名绝对差;各通道包含签名值。
uaba uaba Vd.t1, Vl.t1, Vr.t1 向量无符号绝对差并累加;Vd = Vd + abs(Vl – Vr),其中各通道包含无符号值。
saba saba Vd.t1, Vl.t1, Vr.t1 向量有符号绝对差并累加;Vd = Vd + abs(Vl – Vr),其中通道包含有符号值。
uabdl uabdl Vd.t2, Vl.t3, Vr.t3 向量无符号绝对差长整型;通道包含无符号值。
sabdl sabdl Vd.t2, Vl.t3, Vr.t3 向量有符号绝对差长整型;通道包含有符号值。
uabal uabal Vd.t2, Vl.t3, Vr.t3 向量无符号绝对差长整型并累加;Vd = Vd + abs(Vl – Vr),其中通道包含无符号值。
sabal sabal Vd.t2, Vl.t3, Vr.t3 向量有符号绝对差长整型并累加;Vd = Vd + abs(Vl – Vr),其中通道包含有符号值。
uabdl2 uabdl2 Vd.t4, Vl.t5, Vr.t5 向量无符号绝对差长整型;通道包含无符号值。使用 Vl 和 Vr 的高 64 位。
sabdl2 sabdl2 Vd.t4, Vl.t5 Vr.t5 向量有符号绝对差长整型;通道包含有符号值。使用 Vl 和 Vr 的高 64 位。
uabal2 uabal2 Vd.t4, Vl.t5, Vr.t5 向量无符号绝对差长整型并累加;Vd = Vd + abs(Vl – Vr),其中通道包含无符号值。使用 Vl 和 Vr 的高 64 位。
sabal2 sabal2 Vd.t4, Vl.t5, Vr.t5 向量有符号绝对差长整型并累加;Vd = Vd + abs(Vl – Vr),其中通道包含有符号值。使用 Vl 和 Vr 的高 64 位。
fabd fabd Vd.t6, Vl.t6, Vr.t6 向量浮点绝对差;通道包含浮点值。
fabd fabd Sd, Sl, Sr 标量单精度浮点绝对差;Sd = abs(Sl – Sr)。
fabd fabd Dd, Dl, Dr 标量双精度浮点绝对差;Dd = abs(Dl – Dr)。

表 11-13 列出了绝对差指令的合法类型。

表 11-13:绝对差指令的合法类型

t 合法类型
t1 8B, 16B, 4H, 8H, 2S, 或 4S
t2/t3 8H/8B, 4S/4H, 或 2D/2S
t4/t5 8H/16B, 4S/8H, 或 2D/4S
t6 2S, 4S, 或 2D

uabd 和 sabd 指令计算每个通道的差值,取差值的绝对值,并将结果存储到目标通道中。尽管这两条指令分别操作无符号和有符号源操作数,但结果始终是无符号值。实际上,这些只是 sub 指令的变种,它们取结果的绝对值。只要你将结果视为无符号数(尤其是在 sabd 指令的情况下),这些指令不会产生溢出(下溢)。

uaba 和 saba 指令将差值的绝对值加到目标寄存器的相应通道。如果发生溢出(在加法过程中),这些指令将低位(通道大小)存储到相应的目标通道中。对于带符号操作,如果 Vl 包含最小负值(例如,字节类型的 0x80)且 Vr 为 0,则会发生溢出,指令最终将最小负值加到目标通道中。

这些指令的后缀-l 和后缀-l2 变体执行长整型计算。uabdl 和 sabdl 指令首先将通道值进行零扩展或符号扩展(分别),将其扩展为原来通道大小的两倍,然后计算绝对值差,并将结果存入相应的双倍大小通道中。uabdl2 和 sabdl2 指令执行相同的操作,但从源操作数的高 64 位获取通道数据(请参见图 11-21 和图 11-22,并替换适当的指令,查看具体实现)。

fabd 指令计算两个浮点值的绝对差。对于向量寄存器操作数,它一次处理两个双精度或四个单精度浮点值。该指令也支持标量操作(单精度或双精度),通过指定 Dn 或 Sn 寄存器作为操作数。不幸的是,目前没有浮点数绝对差和累加指令。你可以通过在 fabd 指令后跟一个 fadd 指令来模拟该指令(使用一个备用向量寄存器来存储 fabd 的临时结果)。

11.7.4 向量乘法

Neon 指令集包括几条指令,用于计算向量寄存器中对应通道的乘积(包括整数和浮点数乘积)。标准的向量乘法指令见表 11-14。请注意,Vl 是左操作数,Vr 是右操作数。

表 11-14:Neon 向量乘法指令

助记符 语法 描述
mul mul Vd.t1, Vl.t1, Vr.t1 乘法:Vd = Vl × Vr。忽略溢出,保留结果的低位(逐通道)。
mla mla Vd.t1, Vl.t1, Vr.t1 乘法并累加:Vd = Vd + Vl × Vr。忽略溢出,保留结果的低位(逐通道)。
mls mls Vd.t1, Vl.t1, Vr.t1 乘法并减法:Vd = Vd − Vl × Vr。忽略溢出,保留结果的低位(逐通道)。
smull smull Vd.t2, Vl.t3, Vr.t3 带符号扩展乘法:Vd = Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将扩展精度的结果存储到 Vd 的各个通道中(双通道大小,逐通道结果)。
umull umull Vd.t2, Vl.t3, Vr.t3 无符号扩展乘法:Vd = Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将扩展精度的结果存储到 Vd 的各个通道中(双通道大小,逐通道结果)。
smull2 smull2 Vd.t4, Vl.t5, Vr.t5 带符号扩展乘法:Vd = Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将扩展精度结果存储在 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
umull2 umull2 Vd.t4, Vl.t5, Vr.t5 无符号扩展乘法:Vd = Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将扩展精度结果存储在 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
smlal smlal Vd.t2, Vl.t3, Vr.t3 带符号扩展乘法并累加:Vd = Vd + Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将扩展精度结果加到 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
umlal umlal Vd.t2, Vl.t3, Vr.t3 无符号扩展乘法并累加:Vd = Vd + Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将扩展精度结果加到 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
smlal2 smlal2 Vd.t4, Vl.t5, Vr.t5 带符号扩展乘法并累加:Vd = Vd + Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将扩展精度结果加到 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
umlal2 umlal2 Vd.t4, Vl.t5, Vr.t5 无符号扩展乘法并累加:Vd = Vd + Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将扩展精度结果加到 Vd 的各个 lane 中(双 lane 大小,逐 lane 结果)。
smlsl smlsl Vd.t2, Vl.t3, Vr.t3 带符号扩展乘法并减法:Vd = Vd – Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将其从 Vd 的扩展精度值中减去(双 lane 大小,逐 lane 结果)。
umlsl umlsl Vd.t2, Vl.t3, Vr.t3 无符号扩展乘法并减法:Vd = Vd – Vl × Vr。将 Vl 的低半部分与 Vr 相乘,并将其从 Vd 的扩展精度值中减去(双 lane 大小,逐 lane 结果)。
smlsl2 smlsl2 Vd.t4, Vl.t5, Vr.t5 带符号扩展乘法并减法:Vd = Vd – Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将其从 Vd 的扩展精度值中减去(双 lane 大小,逐 lane 结果)。
umlsl2 umlsl2 Vd.t4, Vl.t5, Vr.t5 无符号扩展乘法并减法:Vd = Vd – Vl × Vr。将 Vl 的高半部分与 Vr 相乘,并将其从 Vd 的扩展精度值中减去(双 lane 大小,逐 lane 结果)。
fmul fmul Vd.t6, Vl.t6, Vr.t6 浮点乘法:Vd = Vl × Vr。将 Vl 和 Vr 各自 lane 中的浮点数值相乘,并将乘积存储到对应的 Vd lanes 中(逐 lane)。
fmulx fmulx Vd.t6, Vl.t6, Vr.t6 浮点乘法:Vd = Vl × Vr。将 Vl 和 Vr 各自 lane 中的浮点数值相乘,并将乘积存储到对应的 Vd lanes 中(逐 lane)。该变体处理一个源操作数为 0,另一个为±∞的情况,结果为±2(当是–∞时为–2,否则为+2)。
fmla fmla Vd.t6, Vl.t6, Vr.t6 浮点乘法并累加:Vd = Vd + Vl × Vr(逐 lane)。
fmls fmls Vd.t6, Vl.t6, Vr.t6 浮点乘法减法:Vd = Vd – Vl × Vr(逐 lane)。

表 11-15 列出了表 11-14 中指令的合法类型。

表 11-15:向量乘法指令的合法类型

t 类型 备注
t1 8B、16B、4H、8H、2S 或 4S 8B、4S 和 2S 仅在 LO 64 位上操作。
t2/t3 8H/8B、4S/4H 或 2D/2S t3 lanes 来源于 LO 64 位。
t4/t5 8H/16B、4S/8H 或 2D/4S t5 lanes 来源于 HO 64 位。
t6 2S、4S 或 2D

还有 pmul、pmull 和 pmull2(多项式乘法)指令。然而,多项式乘法并不是传统的乘法操作,关于这一点的讨论超出了本书的范围。有关这些指令的更多细节,请参阅 Arm 文档。

11.7.4.1 向量饱和乘法与双倍

向量饱和乘法和双倍指令基于标准的乘法、乘法累加和乘法减法指令,产生扩展精度(长整型)结果,将乘积加倍并使结果饱和。本指令集中的指令列在表 11-16 中,计算公式为 Vd = 饱和({Vd ±}(Vl × Vr) × 2),其中 Vd 是目标操作数,Vl 是左操作数,Vr 是右操作数;{Vd ±}表示 Vd ±是可选的源操作数。

表 11-16:带饱和的向量乘法和双倍指令

助记符 语法 描述
sqdmull sqdmull Vd.t1, Vl.t2, Vr.t2 Vd = (Vl × Vr) × 2(逐 lane)
sqdmlal sqdmlal Vd.t1, Vl.t2, Vr.t2 Vd = Vd + (Vl × Vr) × 2(逐 lane)
sqdmlsl sqdmlsl Vd.t1, Vl.t2, Vr.t2 Vd = Vd − (Vl × Vr) × 2(逐 lane)
sqdmull2 sqdmull2 Vd.t3, Vl.t4, Vr.t4 Vd = (Vl × Vr) × 2(逐 lane,HO 64 位来源)
sqdmlal2 sqdmlal2 Vd.t3, Vl.t4, Vr.t4 Vd = Vd + (Vl × Vr) × 2(逐 lane,HO 64 位)
sqdmlsl2 sqdmlsl2 Vd.t3, Vl.t4, Vr.t4 Vd = Vd – (Vl × Vr) × 2(逐 lane,HO 64 位)
sqdmull sqdmull Vd.t5, Vl.t6, Vr.t7[x] Vd = (Vl × Vr) × 2(Vl lanes × Vr[x] 标量)
sqdmlal sqdmlal Vd.t5, Vl.t6, Vr.t7[x] Vd = Vd + (Vl × Vr) × 2(Vl lanes × Vr[x] 标量)
sqdmlsl sqdmlsl Vd.t5, Vl.t6, Vr.t7[x] Vd = Vd – (Vl × Vr) × 2(Vl lanes × Vr[x] 标量)
sqdmull2 sqdmull2 Vd.t8, Vl.t9, Vr.t10[x] Vd = (Vl × Vr) × 2(Vl lanes × Vr[x] 标量,HO 64 位)
sqdmlal2 sqdmlal2 Vd.t8, Vl.t9, Vr.t10[x] Vd = Vd + (Vl × Vr) × 2(Vl lanes × Vr[x] 标量,HO 64 位)
sqdmlsl2 sqdmlsl2 Vd.t8, Vl.t9, Vr.t10[x] Vd = Vd – (Vl × Vr) × 2(Vl lanes × Vr[x] 标量,HO 64 位)

合法的类型和 lane 数目列在表 11-17 中。

表 11-17:带饱和的向量乘法和双倍合法类型与 lane 数目

t 类型和 lane 数目
t1/t2 4S/4H 或 2D/2S
t3/t4 4S/8H,或 2D/4S
t5/t6/t7 4S/4H/H,或 2D/2S/S
t8/t9/t10 4S/8H/H,或 2D/4S/S

这些指令都将其源操作数进行符号扩展,扩展为原来大小的两倍,并进行乘法运算,生成一个乘积。然后,它们将该乘积乘以 2。标准的乘法变体会饱和并将该乘积存储到相应的目标 lane 中。乘法累加变体会将乘积(乘以 2)加到目标中,并饱和结果。乘法减法变体会将乘积(乘以 2)从目标中减去,并饱和结果。

没有 2 后缀的指令从第一个源寄存器的 LO 64 位中提取它们的 lane,而带有 2 后缀的指令则从第二个源寄存器的 HO 64 位中提取它们的 lane。

表 11-16 中的最后六条指令将 Vl 中的各条 lane 与从 Vr 中的某一 lane 选择的标量值相乘(由[x]索引操作符选择)。这里,x 必须是适合源类型的值(字节为 0 到 7,半字为 0 到 3,字为 0 到 1)。对于最后六种形式,如果 t10 是 H,则 Vr 寄存器号(r)必须在 0 到 15 的范围内。

有几个“简短”版本的 sqdmul*指令不将目标寄存器中的类型大小加倍:sqdmulh 和 sqrdmulh。这些指令也会对源操作数进行相乘,将结果加倍并饱和。但是,它们仅将结果的 HO 64 位存储到目标 lane 中(经过饱和和可能的四舍五入)。表 11-18 列出了这些指令。

表 11-18:饱和乘法和双倍指令,HO 位

助记符 语法 描述
sqdmulh sqdmulh Vd.t1, Vl.t1, Vr.t1 按 lane 逐个相乘,结果加倍,饱和并保留乘积的 HO 半部分。
sqrdmulh sqrdmulh Vd.t1, Vl.t1, Vr.t1 按 lane 逐个相乘,结果加倍,四舍五入,饱和并保留乘积的 HO 半部分。
sqdmulh sqdmulh Vd.t2, Vl.t3, Vr.t4[x] 将 Vl 中的各条 lane 与 Vr[x]选择的标量相乘;将结果加倍,饱和并保留乘积的 HO 半部分。
sqrdmulh sqrdmulh Vd.t2, Vl.t3, Vr.t4[x] 将 Vl 中的各条 lane 与 Vr[x]选择的标量相乘;将结果加倍,四舍五入,饱和并保留乘积的 HO 半部分。
sqdmulh sqdmulh Rd, Rl, Rr sqdmulh 的标量版本。
sqrdmulh sqrdmulh Rd, Rl, Rr sqrdmulh 的标量版本。

在本表中,t1 是 4H、8H、2S 或 4S。对于 4H 和 2S 类型,指令仅处理寄存器的 LO 64 位;而 8H 和 4S 类型使用寄存器的全部 128 位。

类型说明 t2/t3/t4 是 4H/4H/H,8H/8H/H,2S/2S/S,或 4S/S;4H 和 2S 类型与寄存器的 LO 64 位一起使用,而 8H 和 4S 类型则处理寄存器的全部 128 位。如果类型是 H,则 Vr 的寄存器号必须在 0 到 15 的范围内。

如果[x]索引出现在 Vr.t4 后面,则该指令将 Vl 中的通道与从 Vr 的通道 x 提取的标量值相乘,Vr 必须是适合源类型的值(字节为 0 到 7,半字为 0 到 3,字为 0 到 1)。

这些指令有两种标量变体。R(在 Rd、Rl 和 Rr 中)必须是 H 或 S。例如

sqdmulh h0, h1, h2

计算 H0 = 饱和(H1 × H2 × 2)。

11.7.4.2 向量与标量元素相乘

Neon 指令集提供了多条指令,将向量的所有元素与单一标量值相乘,如表 11-19 所列。

表 11-19:向量与标量相乘指令

记忆法 语法 描述
mul mul Vd.t1, Vl.t1, Vr.t2[x] 将整数向量元素与标量值相乘。将 Vl 中的每个通道与 Vr[x](标量值)相乘,并将乘积存储到 Vd 中相应的通道(即,对于每个通道 i,Vd[i] = Vl[i] × Vr[x])。
mla mla Vd.t1, Vl.t1, Vr.t2[x] 将向量元素与标量相乘并累加。对于每个通道 i,Vd[i] = Vd[i] + Vl[i] × Vr[x]。
mls mls Vd.t1, Vl.t1, Vr.t2[x] 将向量元素与标量相乘并减法。对于每个通道 i,Vd[i] = –d[i] – Vl[i] × Vr[x]。
smull smull Vd.t3, Vl.t4, Vr.t5[x] 有符号向量与标量相乘,长整型。将 Vl 中的(LO)通道扩展为两倍大小,乘以 Vr[x],并将结果存储到 Vd 中双倍大小的通道。仅使用 Vl 的 LO 64 位。
smlal smlal Vd.t3, Vl.t4, Vr.t5[x] 有符号向量与标量相乘并累加,长整型。类似于 smull,但将乘积累加到 Vd 中,而不是仅仅存储到 Vd 中。
smlsl smlsl Vd.t3, Vl.t4, Vr.t5[x] 有符号向量与标量相乘并减法,长整型。类似于 smull,但将乘积从 Vd 中减去,而不是仅仅存储到 Vd 中。
smull2 smull2 Vd.t6, Vl.t7, Vr.t8[x] 有符号向量与标量相乘,长整型。将 Vl 中的(HO)通道扩展为两倍大小,乘以 Vr[x],并将结果存储到 Vd 中双倍大小的通道。仅使用 Vl 的 HO 64 位。
smlal2 smlal2 Vd.t6, Vl.t7, Vr.t8[x] 有符号向量与标量相乘并累加,长整型(HO 源)。类似于 smull2,但将乘积累加到 Vd 中,而不是仅仅存储到 Vd 中。
smlsl2 smlsl2 Vd.t6, Vl.t7, Vr.t8[x] 有符号向量与标量相乘并减法,长整型(HO 源)。类似于 smull2,但将乘积从 Vd 中减去,而不是仅仅存储到 Vd 中。
umull umull Vd.t3, Vl.t4, Vr.t5[x] 无符号向量与标量相乘,长整型。将 Vl 中的(LO)通道扩展为两倍大小,乘以 Vr[x],并将结果存储到 Vd 中双倍大小的通道。仅使用 Vl 的 LO 64 位。
umlal umlal Vd.t3, Vl.t4, Vr.t5[x] 无符号向量与标量相乘并累加,长整型。类似于 umull,但将乘积累加到 Vd 中,而不是仅仅存储到 Vd 中。
umlsl umlsl Vd.t3, Vl.t4, Vr.t5[x] 无符号向量与标量相乘并相减,长整型。与 umull 类似,但将乘积从 Vd 中减去,而不仅仅是存储到 Vd。
umull2 umull2 Vd.t6, Vl.t7, Vr.t8[x] 无符号向量与标量相乘,长整型。将 Vl 中的(HO)通道零扩展到其大小的两倍,与 Vr[x] 相乘,并将结果存储到 Vd 中的双倍大小的通道中。仅使用 Vl 中的 HO 64 位。
umlal2 umlal2 Vd.t6, Vl.t7, Vr.t8[x] 无符号向量与标量相乘并累加,长整型(HO 源)。与 umull2 类似,但将乘积累加到 Vd 中,而不仅仅是存储到 Vd。
umlsl2 umlsl2 Vd.t6, Vl.t7, Vr.t8[x] 无符号向量与标量相乘并相减,长整型(HO 源)。与 umull2 类似,但将乘积从 Vd 中减去,而不仅仅是存储到 Vd。
fmul fmul Vd.t9, Vl.t10, Vr.t11[x] 浮点向量与标量相乘。将 Vl 中每个通道与 Vr[x](标量值)相乘,并将乘积存储到 Vd 的相应通道中(即,对于每个通道 i,Vd[i] = Vl[i] × Vr[x])。
fmulx fmulx Vd.t9, Vl.t10, Vr.t11[x] 类似于 fmul,但它是一个特殊变体,处理其中一个源操作数为 0,另一个为 ±∞ 的情况。这样会生成值 ±2(如果是 -∞ 则为 -2,否则为 +2)。
fmla fmla Vd.t9, Vl.t10, Vr.t11[x] 浮点向量与标量相乘并累加。将 Vl 中每个通道与 Vr[x](标量值)相乘,并将乘积加到 Vd 的相应通道中(即,对于每个通道 i,Vd[i] = Vd[i] + Vl[i] × Vr[x])。
fmls fmls Vd.t9, Vl.t10, Vr.t11[x] 浮点向量与标量相乘并相减。将 Vl 中每个通道与 Vr[x](标量值)相乘,并将乘积从 Vd 的相应通道中减去(即,对于每个通道 i,Vd[i] = Vd[i] - Vl[i] × Vr[x])。

表 11-20 列出了 表 11-19 中指令的合法类型和通道数量。

表 11-20:向量与标量相乘的合法类型和通道数量

t 合法类型和通道数量
t1/t2 4H/H,8H/H,2S/S,或 4S/S
t3/t4/t5 4S/4H/H 或 2D/2S/S
t6/t7/t8 4S/8H/H,或 2D/4S/S
t9/t10/t11 2S/2S/S,4S/S,或 2D/D

图 11-26 显示了 mul、mla、mls、fmul、fmla 和 fmls 指令的基本操作。

图 11-26:向量与标量相乘操作

图 11-27 显示了 smull、umull、smlal、umlal、smlsl 和 umlsl 指令的基本操作。

图 11-27:向量与标量相乘,长整型(LO 位)

图 11-28 显示了 smull2、umull2、smlal2、umlal2、smlsl2 和 umlsl2 指令的基本操作。

图 11-28:向量与标量相乘,长整型(HO 位)

由于两个n位数的乘积适合存储在 2n位中,smul/smul2 和 umul/umul2 指令不会产生溢出。但是,请注意,在乘法之后的加法或减法可能需要一个附加位(2n + 1 位)。如果发生这种情况,这些指令将忽略溢出并保留 LO 位。

11.7.4.3 标量乘法与向量元素

Neon 指令集提供了 fmls 指令的变体,将标量寄存器(Sn 或 Dn)与向量元素(Vn[x])相乘,并将结果存储回标量寄存器中。表 11-21 列出了这些指令的语法,其中 Fl 是左侧源操作数,Vr 是右侧源操作数。

表 11-21:浮点标量乘法与向量元素指令

助记符 语法 描述
fmul fmul Fd, Fl, Vr.t[x] Fd = Fl × Vr.t[x]
fmulx fmulx Fd, Fl, Vr.t[x] Fd = Fl × Vr.t[x]。处理 Fl = 0.0 且 Vr.t 为±∞的情况,结果为±2.0。
fmla fmla Fd, Fl, Vr.t[x] Fd = Fd + Fl × Vr.t[x]
fmls fmls Fd, Fl, Vr.t[x] Fd = Fd – Fl × Vr.t[x]

寄存器 Fd 和 Fl 分别是标量浮点寄存器(Sn 或 Dn)之一。类型 t 必须匹配大小(S 或 D)。如果类型是单精度(Sn),则 Vr 必须是 V0 到 V15 范围内的寄存器。

这些乘法指令没有整数等效指令。

11.7.5 向量除法

Neon 指令集不提供执行整数除法的向量指令。然而,它确实提供了一条指令来执行一对向量中每个通道的浮点数除法。

fdiv V`d.t`, V`l.t`, V`r.t`  // Computes V`d` = V`l` / V`r` (lane by lane)

其中 t 是 2S,4S 或 2D。(除以零会在目标通道中产生 NaN。)

由于浮点数除法相对较慢,特别是在遍历所有通道时,Neon 指令集包含一对可以计算浮点数倒数的指令。与除法相比,乘以倒数通常要快得多。如果你正在除以一个常量,可以在汇编时预先计算倒数值,并使用该值(没有运行时开销)。如果值是一个无法在汇编时计算的变量,可以使用 frecpe 指令来近似所有通道中向量寄存器的倒数。

frecpe V`d.t`, V`s.t`

其中 t 是 2S,4S 或 2D(2S 操作寄存器的低 64 位)。

存在 frecpe 的标量版本。

frecpe `Rd`, `Rs`

其中 Rd 和 Rs 是 Sn或 Dn

frecpe 指令产生一个倒数近似值,其与正确值的误差不超过 8 位——虽然不太精确,但足以应对快速而粗略的计算。如果你需要更好的精度,可以使用 frecps 指令(语法相同,仅助记符不同)来计算 Newton-Raphson 倒数近似算法的另一步,代码如下:

// Compute V0.4S = V1.4S / V2.4S by computing the reciprocal
// of V2 and multiplying V1 by this reciprocal value:

    frecpe  v3.4s, v2.4s         // Get first approximation.
    frecps  v0.4s, v1.4s, v3.4s  // *** Refinement step
    fmul    v3.4s, v3.4s, v0.4s  // *** Refinement step (cont.)

// Repeat "Refinement step" as many times as desired here.

    fmul    v0.4s, v1.4s, v3.4s  // Compute quotient.

你重复细化步骤的次数越多,结果就越精确。然而,在某个时刻,执行这些浮点指令的成本将超过单个 fdiv 指令的执行时间,因此要小心使用 frecps,因为它的收益递减。

有一个 urecpe 指令用于估算定点倒数,但定点运算超出了本书的范围。如需了解更多内容,请参阅 ARM 架构参考手册,该手册在第 11.15 节“更多信息”中提供链接,见第 700 页。

11.7.6 符号操作

Neon 指令集包括四条指令,可以使你对向量寄存器中的各条进行取反或取绝对值操作。

abs   V`d.t1`, V`s.t1`
neg   V`d.t1`, V`s.t1`
sqabs V`d.t1`, V`s.t1`
sqneg V`d.t1`, V`s.t1`
fabs  V`d.t2`, V`s.t2`
fneg  V`d.t2`, V`s.t2`

其中 t1 代表常见的整数类型(8B、16B、4H、8H、2S、4S 或 2D),t2 代表常见的浮点类型(2S、4S 和 2D)。8B、4H 和 2S 类型仅引用向量寄存器中的低 64 位。

abs 和 fabs 指令计算源寄存器中每个条目的绝对值,并将结果存储到目标寄存器中。显然,abs 操作有符号整数值,而 fabs 操作浮点值。

neg 和 fneg 指令对源寄存器中的条目取反(改变符号),并将取反后的结果保存在对应的目标条目中。如预期,neg 作用于有符号整数,fneg 作用于浮点值。

sqabs 和 sqneg 指令是 abs 和 neg 指令的特殊饱和变体,永远不会溢出。当你取其绝对值或取反时,最小值(例如,字节值的 0x80)会发生溢出;在这两种情况下,结果都将是相同的。sqabs 和 sqneg 指令将在你尝试取反或取绝对值时,产生最大正值(例如,字节值的 0x7F)。

abs、neg、sqabs 和 sqneg 指令也有标量版本,如表 11-22 所示。对于 abs 和 neg,Rd 和 Rs 只能是 Dn;对于 sqabs 和 sqneg,Rd 和 Rs 可以是标量寄存器 Bn、Hn、Sn 或 Dn。

表 11-22:标量符号操作

助记符 语法 描述
abs abs Rd, Rs Rd = abs(Rs)
neg neg Rd, Rs Rd = –Rs
sqabs sqabs Rd, Rs Rd = abs(Rs),饱和到最大有符号值
sqneg sqneg Rd, Rs Rd = –Rs,饱和到有符号范围

表 11-22 中的指令作用于指定寄存器中的标量值。

11.7.7 最小值和最大值

Neon 指令集提供了几条指令,从两个向量寄存器中选择对应条目的最小值或最大值,并将该值存储到目标寄存器的对应条目中,如表 11-23 所示。

表 11-23:向量最小值和最大值指令

助记符 语法 描述
smin smin Vd.t1, Vl.t1, Vr.t1 Vd = min(Vl, Vr)(有符号整数值)
smax smax Vd.t1, Vl.t1, Vr.t1 Vd = max(Vl, Vr)(有符号整数值)
umin umin Vd.t1, Vl.t1, Vr.t1 Vd = min(Vl, Vr)(无符号整数值)
umax umax Vd.t1, Vl.t1, Vr.t1 Vd = max(Vl, Vr)(无符号整数值)
fmin fmin Vd.t2, Vl.t2, Vr.t2 Vd = min(Vl, Vr)(浮点值)
fmax fmax Vd.t2, Vl.t2, Vr.t2 Vd = max(Vl, Vr)(浮点值)
fminnm fminnm Vd.t2, Vl.t2, Vr.t2 Vd = min(Vl, Vr)(浮点值)
fmaxnm fmaxnm Vd.t2, Vl.t2, Vr.t2 Vd = max(Vl, Vr)(浮点值)

在此表中,t1 必须是 8B、16B、4H、8H、2S 或 4S。如果 t1 是 8B、4H 或 2S,则指令仅作用于向量寄存器中低 64 位的通道;如果是 16B、8H 或 4S,则指令作用于整个 128 位的向量寄存器。

类型 t2 必须是 2S、4S 或 2D。如果是 2S,指令仅作用于向量寄存器中低 64 位的通道;否则,作用于整个 128 位。

fmin 和 fmax 指令如果对应源通道之一(或两个)包含 NaN,则返回 NaN。而 fminnm 和 fmaxnm 指令则在一个通道包含有效数字,另一个通道包含 NaN 时返回有效数字。如果两个通道都包含有效的浮点值,则所有四条指令的行为相同,返回最小值或最大值(视情况而定)。

11.7.7.1 逐对最小值与最大值

最小值和最大值指令也有逐对变体,如表 11-24 所示,其中 t1 和 t2 与逐通道指令相同。

表 11-24:逐对最小值与最大值指令

助记符 语法 描述 作用于
sminp sminp Vd.t1, Vl.t1, Vr.t1 Vd = pairwise_min(Vl, Vr) 有符号整数
smaxp smaxp Vd.t1, Vl.t1, Vr.t1 Vd = pairwise_max(Vl, Vr) 有符号整数
uminp uminp Vd.t1, Vl.t1, Vr.t1 Vd = pairwise_min(Vl, Vr) 无符号整数
umaxp umaxp Vd.t1, Vl.t1, Vr.t1 Vd = pairwise_max(Vl, Vr) 无符号整数
fminp fminp Vd.t2, Vl.t2, Vr.t2 Vd = pairwise_min(Vl, Vr) 浮点值
fmaxp fmaxp Vd.t2, Vl.t2, Vr.t2 Vd = pairwise_max(Vl, Vr) 浮点值
fminnmp fminnmp Vd.t2, Vl.t2, Vr.t2 Vd = pairwise_min(Vl, Vr) 浮点值
fmaxnmp fmaxnmp Vd.t2, Vl.t2, Vr.t2 Vd = pairwise_max(Vl, Vr) 浮点值

逐对拓扑结构与 addp 指令相同(有关 uminp 示例,见图 11-29)。

图 11-29:逐对最小值与最大值操作

还有一组逐对标量浮点数最小值和最大值指令,如表 11-25 所示,其中 Rd/t 必须是 Sn/2S 或 Dn/2D。

表 11-25:成对标量浮点最小值和最大值指令

助记符 语法 描述
fmaxp fmaxp Rd, Vs.t Rd = max(Vs)
fmaxnmp fmaxnmp Rd, Vs.t Rd = max(Vs)(选择数字而非 NaN)
fminp fminp Rd, Vs.t Rd = min(Vs)
fminnmp fminnmp Rd, Vs.t Rd = min(Vs)(选择数字而非 NaN)

这些指令没有整数版本。

11.7.7.2 水平最小值和最大值

水平最小值和最大值指令选择单个向量内的最小值或最大值,如表 11-26 所示。

表 11-26:水平(跨向量)最小值和最大值指令

助记符 语法 描述
sminv sminv Rd, Vs.t1 从 Vs 中提取最小有符号通道值并存储到 Rd 中。
smaxv smaxv Rd, Vs.t1 从 Vs 中提取最大有符号通道值并存储到 Rd 中。
uminv uminv Rd, Vs.t1 从 Vs 中提取最小无符号通道值并存储到 Rd 中。
umaxv umaxv Rd, Vs.t1 从 Vs 中提取最大无符号通道值并存储到 Rd 中。
fminv fminv Sd, Vs.t2 从 Vs 中提取最小实数通道值并存储到 Sd 中。
fmaxv fmaxv Sd, Vs.t2 从 Vs 中提取最大实数通道值并存储到 Rd 中。
fminnmv fminnmv Sd, Vs.t2 从 Vs 中提取最小实数通道值并存储到 Sd 中。
fmaxnmv fmaxnmv Sd, Vs.t2 从 Vs 中提取最大实数通道值并存储到 Rd 中。

在此表中,Rd/t1 是 B/8B,B/16B,H/4H,H/8H,或 S/4S。如果 t1 是 8B 或 4H,则指令仅在 Vs 的低 64 位通道上操作。对于浮点最小值和最大值,只有单精度操作数是合法的;t2 必须是 2S 或 4S(在源寄存器的低 64 位或完整 128 位上操作)。至于标准的 fmin 和 fmax 指令,nm 变种的不同之处在于,如果操作数之一是 NaN,则返回数值。

11.8 浮点数与整数转换

Neon 指令集提供了若干指令,用于在浮点数和整数(或定点数)格式之间进行转换。第 6.9.4 节“浮点转换指令”中,第 343 页提供了在标量寄存器上操作时这些转换指令的示例;以下子节展示了其向量等价版本。

11.8.1 浮点数到整数

Neon 指令集提供了 fcvt*指令的向量等价版本,这些指令将浮点值转换为其整数等价物,如表 11-27 所示。

表 11-27:浮点数到整数转换指令

助记符 语法 描述
fcvtns fcvtns Vd.t, Vs.t 四舍五入到最接近的有符号整数。正好一半时四舍五入到最接近的偶数整数。
fcvtas fcvtas Vd.t, Vs.t 四舍五入到最接近的有符号整数。正好一半时舍去零。
fcvtps fcvtps Vd.t, Vs.t 向+∞方向四舍五入(有符号整数)。
fcvtms fcvtms Vd.t, Vs.t 向下舍入到–∞(有符号整数)。
fcvtzs fcvtzs Vd.t, Vs.t 向 0 舍入(有符号整数)。
fcvtnu fcvtnu Vd.t, Vs.t 四舍五入至最近的无符号整数。正好一半时四舍五入到最近的偶数。
fcvtau fcvtau Vd.t, Vs.t 向最近的无符号整数舍入。正好一半时远离 0 舍入。
fcvtpu fcvtpu Vd.t, Vs.t 向+∞舍入(无符号整数)。
fcvtmu fcvtmu Vd.t, Vs.t 向下舍入到–∞(无符号整数)。
fcvtzu fcvtzu Vd.t, Vs.t 向 0 舍入(无符号整数)。

在此表中,t 是 2S(仅使用向量寄存器的 LO 64 位)、4S 或 2D。源操作数始终假定包含浮点值(单精度或双精度),目标通道将接收有符号或无符号整数值(字或双字)。注意,当将负浮点值转换为无符号整数时,转换会饱和到 0.0。

fcvtz*指令也有一些定点变体:

fcvtzs V`d.t`, V`s.t`, #`imm`
fcvtzu V`d.t`, V`s.t`, #`imm`

imm 操作数指定要在定点值中保留的小数位数(对于单精度或字类型,必须是 1 到 31,对于双精度或双字类型,必须是 1 到 63)。由于整数操作比浮点计算要稍快,因此有时将操作数转换为定点,执行一系列计算,然后将结果转换回浮点数可能更快。但是,本书不会深入讨论定点运算,所以不会进一步讨论这种技巧。有关更多信息,请参见第 11.15 节,“更多信息”,第 700 页。

11.8.2 整数到浮点数

ucvtf 和 scvtf 指令分别将 32 位和 64 位整数转换为单精度和双精度值。它们的语法大致与 fcvt*相同:

scvtf V`d.t`, V`s.t`
ucvtf V`d.t`, V`s.t`

与 fcvt*一样,t 必须是 2S、4S 或 2D(2S 只转换 LO 的 64 位)。

由于双精度值只有 56 位尾数,而单精度值只有 24 位尾数,因此不能精确表示某些 32 位和 64 位整数为单精度或双精度浮点数。在这些情况下,scvtf 和 ucvtf 指令会产生最接近的近似值。然而,请记住,执行cvtf 后跟 fcvt指令可能无法返回完全相同的整数。

11.8.3 浮点数格式之间的转换

Neon 指令集提供了三条指令,可以将较小的浮点格式转换为较大的格式,或者将较大的格式转换为较小的格式。这是 ARM 指令集中少数支持半精度(16 位)浮点数的指令之一。表 11-28 展示了可用的指令。

表 11-28:浮点数转换指令

助记符 语法 描述 按通道转换
fcvtl fcvtl Vd.t1, Vs.t2 通过使用源寄存器的 LO 64 位,从较小的尺寸转换到下一个较大的尺寸。
fcvtl2 fcvtl2 Vd.t3, Vs.t4 通过使用源寄存器的上 64 位,从较小的尺寸转换到下一个较大的尺寸(不影响目标寄存器的 LO 位)。
fcvtn fcvtn Vd.t5, Vs.t6 通过使用目标寄存器的 LO 64 位,从较大的尺寸转换到较小的尺寸。
fcvtn2 fcvtn2 Vd.t7, Vs.t8 通过使用目标寄存器的 HO 64 位,从较大的尺寸转换到较小的尺寸(不影响目标寄存器的 LO 位)。
fcvtxn fcvtxn Vd.2S, Vs.2D 类似于 fcvtn,除了四舍五入的方式不同(见文本)。
fcvtxn2 fcvtxn2 Vd.4S, Vs.2D 类似于 fcvtn2,除了四舍五入的方式不同(见文本)。

表 11-28 中的指令的合法类型和通道数在表 11-29 中列出,其中 H = 16 位半精度浮点数,S = 32 位单精度浮点数,D = 64 位双精度浮点数。 |

表 11-29:浮点数转换的合法类型和通道数 |

t 类型和通道数
t1/t2 4S/4H 或 2D/2S
t3/t4 4S/8H 或 2D/4S
t5/t6 4H/4S 或 2S/2D
t7/t8 8H/4S 或 4S/2D

从较小尺寸转换到较大尺寸时,总是产生精确结果。从较大尺寸转换到较小尺寸时,可能需要将结果四舍五入以适应较小的尺寸(在最坏的情况下,如果较大的值无法在较小的浮点格式中表示,则会发生溢出或下溢)。 |

在将较大值四舍五入以适应较小格式时,fcvtn 和 fcvtn2 指令使用标准的 IEEE-754 四舍五入到最接近偶数的算法。在某些情况下,这可能无法产生最佳结果。例如,将半精度值转换为双精度值时,通常最好四舍五入到最接近的奇数(这需要两步:将半精度转换为单精度,然后将单精度转换为双精度)。fcvtxn 和 fcvtxn2 指令采用这种非 IEEE 四舍五入方案,以产生更好的结果。 |

11.8.4 四舍五入为最接近的整数浮点值 |

某些算法需要将浮点值四舍五入为整数,但要求结果保持在浮点格式中。表 11-30 中列出的 frint*指令提供了这一功能。 |

表 11-30:四舍五入浮点值为整数值 |

助记符 语法 描述
frintn frintn Vd.t, Vs.t 四舍五入到最接近的整数。恰好一半时四舍五入到最接近的偶数。
frinta frinta Vd.t, Vs.t 四舍五入到最接近的整数。恰好一半时远离 0 进行四舍五入。
frintp frintp Vd.t, Vs.t 向 +∞ 四舍五入。
frintm frintm Vd.t, Vs.t 向 -∞ 四舍五入。
frintz frintz Vd.t, Vs.t 向 0 舍入。
frinti frinti Vd.t, Vs.t 使用 FPCR 舍入模式进行舍入。
frintx frintx Vd.t, Vs.t 使用 FPCR 舍入模式并进行精确度测试的舍入。

在此表格中,t 必须是 2S、4S 或 2D。如果是 2S,这些指令仅使用寄存器的 LO 64 位。

frintx 指令会在四舍五入结果与原始源值不相等时生成浮点不精确结果异常。通常除非你有合适的异常处理程序,否则不会使用此指令。

11.9 向量平方根指令

Neon 指令集提供了两条指令,用于计算浮点值的平方根和计算(并改进)浮点值平方根倒数的估计值。表 11-31 列出了这些指令。

表 11-31:向量平方根指令

助记符 语法 描述 每条操作
fsqrt fsqrt Vd.t, Vs.t 计算源的平方根并存储到目标中
frsqrte frsqrte Vd.t, Vs.t 牛顿-拉夫森法的平方根倒数逼近的第一步
frsqrts frsqrts Vd.t, Vs1.t, Vs2.t 牛顿-拉夫森逼近法的附加步骤

在表格中,t 必须是 2S、4S 或 2D。如果是 2S,这些指令将在向量寄存器的 LO 64 位中操作。

这三条指令也有标量版本

fsqrt   R`d`, R`s`
frsqrte R`d`, R`s`
frsqrts R`d`, R`s1,` R`s2`

其中 Rd 和 Rs 是浮点标量寄存器 Sn 或 Dn 中的一个。

请注意,frsqrts 指令将两个源寄存器中相应的浮点值相乘,将每个积从 3.0 中减去,然后将这些结果除以 2.0,并将结果放入目标寄存器中。

11.10 向量比较

向量比较与普通(通用寄存器)比较本质上不同。当比较通用寄存器(甚至单个浮点标量值)时,ARM CPU 根据比较结果设置条件码;接下来的代码会使用条件分支等方式测试这些条件码。但在进行向量元素比较时,这种方案不起作用,因为 CPU 始终并行执行多个比较。由于只有一组条件码,CPU 无法将多个比较的结果存入条件码中,因此向量比较需要不同的机制才能将比较结果提供给程序。

与生成小于、大于或相等结果的通用比较(在条件码中)不同,向量比较要求进行特定的比较,例如,“该向量的元素是否大于另一个向量的元素?” 结果是每一行比较的真或假。向量比较会将真或假的结果存储到目标向量的相应行中。向量比较使用行中的所有 0 位表示假,使用行中的所有 1 位表示真。

Neon 有两组通用的向量比较指令:一组用于整数比较,另一组用于浮点数比较。以下小节将讨论每种形式。

11.10.1 向量整数比较

表 11-32 列出了通用的向量整数比较指令,其中 t 为 8B、16B、4H、8H、2S、4S 或 2D。对于 8B、4H 和 2S 类型,这些指令仅操作寄存器的低 64 位,并清除目标寄存器的高 64 位。

表 11-32:向量整数比较指令

助记符 语法 描述 按行比较
cmeq cmeq Vd.t, Vl.t, Vr.t 有符号或无符号相等比较
cmhs cmhs Vd.t, Vl.t, Vr.t 无符号大于或等于比较(Vd = Vl ≥ Vr)
cmhi cmhi Vd.t, Vl.t, Vr.t 无符号大于比较(Vd = Vl > Vr)
cmge cmge Vd.t, Vl.t, Vr.t 有符号大于或等于比较(Vd = Vl ≥ Vr)
cmgt cmgt Vd.t, Vl.t, Vr.t 有符号大于比较(Vd = Vl > Vr)

没有 cmne 指令。如果你需要这种比较,你可以反转目标寄存器中的所有位(使用 not 指令),或者你可以使用 0 位表示真,使用 1 位表示假。同样,没有 cmls、cmlo、cmle 或 cmlt 指令;你可以通过反转操作数从 cmgt、cmge、cmhs 或 cmhi 中推导出这些指令。

这些指令有标量变体,如表 11-33 所示,其中 Rd、Rl 和 Rr 必须是 Dn。

表 11-33:标量整数比较指令

助记符 语法 描述 标量寄存器比较
cmeq cmeq Rd, Rl, Rr 有符号或无符号相等比较
cmhs cmhs Rd, Rl, Rr 无符号大于或等于比较(Rd = Rl ≥ Rr)
cmhi cmhi Rd, Rl, Rr 无符号大于比较(Rd = Rl > Rr)
cmge cmge Rd, Rl, Rr 有符号大于或等于比较(Rd = Rl ≥ Rr)
cmgt cmgt Rd, Rl, Rr 有符号大于比较(Rd = Rl > Rr)

存在一组特殊的向量比较指令,用于将单个向量的通道与 0 进行比较。这可以避免为这个常见的情况设置一个包含全 0 的寄存器。可用的指令仅执行有符号比较(因为将无符号值与 0 比较没有太大意义)。表 11-34 列出了这些指令。

表 11-34: 与 0 进行的有符号向量比较

助记符 语法 描述 逐通道与 0 进行比较
cmeq cmeq Vd.t, Vl.t, #0 与 0 相等的有符号比较
cmge cmge Vd.t, Vl.t, #0 向量通道大于或等于 0 的有符号比较
cmgt cmgt Vd.t, Vl.t, #0 向量通道大于 0 的有符号比较
cmle cmle Vd.t, Vl.t, #0 向量通道小于或等于 0 的有符号比较
cmlt cmlt Vd.t, Vl.t, #0 向量通道小于 0 的有符号比较

类型 t 必须是 8B、16B、4H、8H、2S、4S 或 2D。对于 8B、4H 和 2S 类型,这些指令仅作用于寄存器的低 64 位,并清除目标寄存器的高 64 位。对于这些指令,唯一合法的立即数是 0。

表 11-35 列出了这些指令的标量版本。

表 11-35: 与 0 进行的标量向量比较

助记符 语法 描述 与 0 进行的标量寄存器比较
cmeq cmeq Rd, Rl, #0 寄存器等于 0 的有符号比较
cmge cmge Rd, Rl, #0 寄存器大于或等于 0 的有符号比较
cmgt cmgt Rd, Rl, #0 寄存器大于 0 的有符号比较
cmle cmle Rd, Rl, #0 寄存器小于或等于 0 的有符号比较
cmlt cmlt Rd, Rl, #0 寄存器小于 0 的有符号比较

在此表中,Rd、Rl 和 Rr 必须是 Dn。

11.10.2 向量浮点数比较

你也可以比较向量寄存器中各通道的浮点数值。表 11-36 列出了用于此目的的各种 fcm*指令,其中 t 为 2S、4S 或 2D。如果 t 是 2S,这些指令仅使用寄存器的低 64 位。

表 11-36: 向量浮点数比较指令

助记符 语法 描述 逐通道比较
fcmeq fcmeq Vd.t, Vl.t, Vr.t 浮点数相等比较
fcmge fcmge Vd.t, Vl.t, Vr.t 浮点数比较 (Vd = Vl ≥ Vr)
fcmgt fcmgt Vd.t, Vl.t, Vr.t 浮点数比较 (Vd = Vl > Vr)

表 11-37 列出了将向量寄存器的通道与 0.0 进行比较的 fcm*指令变体,其中 t 为 2S、4S 或 2D。如果 t 是 2S,这些指令仅使用寄存器的低 64 位。

表 11-37: 与 0.0 进行的向量浮点数比较

助记符 语法 描述 每个通道与 0.0 进行逐通道比较
fcmeq fcmeq Vd.t, Vl.t, #0 寄存器等于 0.0 的浮点数比较
fcmge fcmge Vd.t, Vl.t, #0 大于或等于 0.0 的浮点数比较
fcmgt fcmgt Vd.t, Vl.t, #0 大于 0.0 的浮点数比较
fcmle fcmle Vd.t, Vl.t, #0 浮点数比较,寄存器小于或等于 0.0
fcmlt fcmlt Vd.t, Vl.t, #0 小于 0.0 的浮点数比较

请注意,立即数常量为 0(与 0.0 比较),尽管这是浮点数比较。此指令的唯一合法操作数是 #0。

至于整数比较,fcm* 指令提供了一组标量指令,这些指令也会将 true(所有 1 位)或 false(所有 0 位)存储到目标寄存器(与 fcmp 指令不同,后者会设置条件代码标志)。表 11-38 列出了这些指令的标量版本,其中 Rd、Rl 和 Rr 必须是 Sn 或 Dn*。

表 11-38:向量浮点数比较的标量变体

助记符 语法 描述 标量寄存器比较(包括与 0.0 比较)
fcmeq fcmeq Rd, Rl, Rr 浮点数相等比较
fcmge fcmge Rd, Rl, Rr 浮点数比较(Rd = Rl ≥ Rr)
fcmgt fcmgt Rd, Rl, Rr 浮点数比较(Rd = Rl > Rr)
fcmeq fcmeq Rd, Rl, #0 等于 0.0 的浮点数比较
fcmge fcmge Rd, Rl, #0 大于或等于 0.0 的浮点数比较
fcmgt fcmgt Rd, Rl, #0 大于 0.0 的浮点数比较
fcmle fcmle Rd, Rl, #0 小于或等于 0.0 的浮点数比较
fcmlt fcmlt Rd, Rl, #0 小于 0.0 的浮点数比较

Neon 还有几个额外的浮点数比较:fac*(向量浮点绝对值比较)。这些指令比较源向量寄存器中相应元素的绝对值,并相应地设置目标寄存器。表 11-39 列出了这些指令。

表 11-39:浮点数绝对值比较

助记符 语法 描述
facge facge Vd.t, Vl.t, Vr.t 浮点数比较(Vd = abs(Vl) ≥ abs(Vr))
facgt facgt Vd.t, Vl.t, Vr.t 浮点数比较(Vd = abs(Vl) > abs(Vr))

没有 faceq 指令,因为没有必要使用;只需使用 fcmeq。

fac* 指令也有标量版本,列在表 11-40 中。

表 11-40:标量浮点绝对值比较

助记符 语法 描述
facge facge Rd, Rl, Rr 浮点数比较(Rd = abs(Rl) ≥ abs(Rr))
facgt facgt Rd, Rl, Rr 浮点数比较(Rd = abs(Rl) > abs(Rr))

请注意,Rd、Rl 和 Rr 必须是 Sn 或 Dn。

11.10.3 向量位测试指令

Neon 指令集提供了 tst 指令的向量版本 cmtst,其语法如下:

cmtst V`d.t`, V`l.t`, V`r.t`

其中,t 可以是 8B、16B、4H、8H、2S、4S 或 2D。如果 t 是 8B、4H 或 2S,则该指令仅对源寄存器的低 64 位进行操作,并清除目标寄存器的高 64 位。

该指令对 Vl 和 Vr 进行逐通道的逻辑 AND 操作。如果结果非零,则将所有 1 位存储到相应的目标通道中。否则,将所有 0 存储到目标通道中。

该指令也有一个标量版本:

cmtst D`d,` D`l,` D`r`

这种形式仅支持 64 位寄存器操作数(Dn)。

11.10.4 向量比较结果

在你的编程经验中,包括使用高级语言(HLLs)时,你可能已经习惯了使用比较结果(如布尔表达式)来改变控制流(例如通过 if/then/else 语句)。向量比较呈现了一种完全不同的范式,因为比较中的各个通道可能产生不同的结果。如何应对这种情况是最好的方式?

首先,考虑简单的内容:涉及 AND、OR 和其他逻辑运算符的复杂布尔表达式。由于向量比较会计算出方便的结果(全是 1 或全是 0),因此很容易计算出类似这样的内容:

(V1 > V2) AND (V3 < V4)  // Assume unsigned 8H lanes.

考虑以下代码:

cmhi v0.4h, v1.4h, v2.4h
cmhi v5.4h, v4.4h, v3.4h  // Same as cmlo v5.4h, v3.4h, v4.4h
and  v0.8b, v0.8b, v5.8b  // (assuming cmlo existed)

这会将先前布尔计算的结果(0x0000 或 0xFFFF)保留在 V0 寄存器的低 64 位中(通道 0 到 3;请记住,and 指令仅允许 8B 和 16B 类型,但它们会产生与 4H 类型合法时相同的结果)。

你可以使用类似的指令序列进行 OR、NOT 以及任何其他逻辑向量操作(见 表 11-2)。此类计算将使用完整布尔求值。

注意

短路求值对于向量操作没有意义;有关完整布尔求值的更多信息,请参见第 7.6.3 节,“使用完整布尔求值的复杂if语句,”见 第 378 页。

如果你绝对、坚决必须基于所有向量比较的结果跳转到某些位置,请记住,分支位置的数量会随着通道数的增加而呈指数增长(具体来说,它有 2^n 种不同的可能性,其中 n 是通道数)。例如,如果你执行以下指令:

cmeq v0.4h, v1.4h, v2.4h

然后,V0 的低 64 位将包含四个布尔值,产生四个比较的 16 种组合。虽然很丑,但你可以创建一个跳转表(参见第 7.6.7.3 节,“间接跳转开关实现,”见 第 391 页),表中有 16 个条目,然后通过如下代码转移控制:

ldr     q3, mask             // mask: .hword 0b1000, 0b100, 0b10, 0b1
cmeq    v0.4h, v1.4h, v2.4h
and     v0.8b, v0.8b, v3.8b  // Keep 1 bit of each lane.
addv    h0, v0.4h            // Merge the bits into H0.
umov    w0, v0.h[0]
adr     x1, JmpTbl
ldr     x0, [x1, x0, lsl #3]
add     x0, x0, x1
br      x0

其中,mask 是:

mask:   .dword  0x0008000400020001

JmpTbl 是一个包含 16 个条目的.dword 表格,其中每个条目是跳转标签的偏移量,基于四个通道比较的真与假所有组合。此代码将通道 0 的位 0 移动到 X0 的位 0,通道 1 的位 0 移动到 X0 的位 1,通道 2 的位 0 移动到 X0 的位 2,通道 3 的位 0 移动到 X0 的位 3。这形成了一个 4 位索引(16 个可能值)到 JmpTbl。

从理论上讲,你可以创建一个包含 16 个条目的跳转表并编写代码来传递控制,但这将非常丑陋,根本不值得认真考虑。

有时你不需要知道矢量比较中匹配的具体配置,只需要知道是否存在任何匹配项。例如,假设你在查找字符字符串中的 0 字节(例如在计算零终止字符串的长度时)。你可以一次从字符串中加载 16 个字符,并通过将它们全部与 0 进行比较来搜索 0 字节:

cmeq  v0.16b, v1.16b, #0  // Assume V1 contains 16 chars.

该指令将 V0 中对应的通道设置为 0xFF,表示 V1 中任何包含 0 字节的通道。你可以使用以下序列来检查是否有任何 0 字节:

cmeq v0.16b, v1.16b, #0  // Check for a 0 byte.
addv b0, v0.16b          // Sum comparison bytes.
umov w0, v0.b[0]         // Put sum where you can compare it.
cmp  w0, #0              // See if there were any 0 bytes.
beq  noZeroBytes         // No 0s, go fetch 16 more bytes.

在 SIMD 范式中,理想的解决方案是并行进行计算,并使用掩码禁用某些计算。例如,假设你有一个 32 位整数的矢量,你希望将另一个矢量的通道加到它上面,前提是你不想对包含大于 16 位(0xFFFF)的值的通道进行加法。考虑以下代码:

// Add V1.S to V0.S, but don't add a value to a particular
// lane in V0 if its value exceeds 0xFFFF.
//
// Note: Assume V2 contains 0x0000FFFF0000ffff0000FFFF0000ffff.

        // no cmls v3.4s, v0.4s, v2.4s, so use the following:

        cmhi    v3.4s, v2.4s, v0.4s

        and     v4.16b, v1.16b, v3.16b
        add     v0.4s, v0.4s, v4.4s

and 指令将大于 0xFFFF 的 dword 通道设置为 0,以便它们不会影响最终的通道和。

11.11 使用 SIMD 代码的排序示例

排序数据是常见的矢量化解决方案。列表 11-1 演示了使用矢量化的比顿排序算法对八个元素进行简单排序。

// Listing11-1.S
//
// Demonstrates a simple bitonic sort
// of eight elements, using vector instructions

        #include    "aoaa.inc"
        .text

        .pool
ttlStr: wastr  "Listing 11-1"

// Format strings for printf:

fmtPV:  wastr   " %016llx %016llx"
nl:     wastr   "\n"

// Sample data to sort
// (eight unsigned 32-bit integers
// to be loaded into vector
// registers):

qval1:  .word   8, 7, 6, 4
qval2:  .word   3, 2, 1, 0

// Lookup tables for TBL instruction,
// used to move around integers
// within the vector registers
//
// TBL works with bytes; the following
// constants map 32-bit integers to
// a block of 4 bytes in the
// vector registers:

_a = 0x03020100
_b = 0x07060504
_c = 0x0b0a0908
_d = 0x0f0e0d0c

_e = 0x13121110
_f = 0x17161514
_g = 0x1b1a1918
_h = 0x1f1e1d1c

_e1 = 0x03020100    // Special case
_f1 = 0x07060504    // for single-
_g1 = 0x0b0a0908    // register lists
_h1 = 0x0f0e0d0c

lut1:   .word   _f1, _e1, _h1, _g1
lut2:   .word   _a, _f, _c, _h
lut3:   .word   _b, _e, _d, _g
lut4:   .word   _h1, _g1, _f1, _e1
lut5:   .word   _a, _b, _g, _h
lut6:   .word   _c, _d, _f, _e
lut7:   .word   _a, _e, _b, _f
lut8:   .word   _c, _g, _d, _h
lut9:   .word   _a, _e, _b, _f
lut10:  .word   _c, _g, _d, _h

// Usual function that returns
// a pointer to the name of this
// program in the X0 register:

        proc    getTitle, public
        lea     x0, ttlStr
        ret
        endp    getTitle

// printV
//
// Prints the two 128-bit values sitting
// on the top of the stack (prior to call)
// as hexadecimal values:

        proc    printV

        locals  p
        qword   p.v0
        qword   p.v1
        qword   p.v2
        qword   p.v3
        qword   p.v4
        byte    p.stk, 64
        endl    p

        enter   p.size

        // Preserve vector registers
        // (this program uses them):

        str     q0, [fp, #p.v0]
        str     q1, [fp, #p.v1]
        str     q2, [fp, #p.v2]
        str     q3, [fp, #p.v3]
        str     q4, [fp, #p.v4]

        // Print the first value on
        // the stack:

        ldr     w1, [fp, #16]
        ldr     w2, [fp, #20]
        ldr     w3, [fp, #24]
        ldr     w4, [fp, #28]
        lea     x0, fmtPV
        mstr    x1, [sp]
 mstr    x2, [sp, #8]
        mstr    x3, [sp, #16]
        mstr    x4, [sp, #24]
        bl      printf

        // Print the second value on
        // the stack:

        ldr     w1, [fp, #32]
        ldr     w2, [fp, #36]
        ldr     w3, [fp, #40]
        ldr     w4, [fp, #44]
        lea     x0, fmtPV
        mstr    x1, [sp]
        mstr    x2, [sp, #8]
        mstr    x3, [sp, #16]
        mstr    x4, [sp, #24]
        bl      printf

        lea     x0, nl
        bl      printf

        ldr     q0, [fp, #p.v0]
        ldr     q1, [fp, #p.v1]
        ldr     q2, [fp, #p.v2]
        ldr     q3, [fp, #p.v3]
        ldr     q4, [fp, #p.v4]
        leave
        endp    printV

// Here's the main program:

        proc    asmMain, public

        // Reserve stack space for parameters:

        locals  am
        byte    am.stk, 64
        endl    am

        enter   am.size

        // Load the values to sort
        // into V0 and V1:

        ldr     q0, qval1
        ldr     q1, qval2

        // Bitonic sort of eight
        // elements:

        // Step 1:

        umin    v2.4s, v0.4s, v1.4s
        umax    v3.4s, v0.4s, v1.4s

 // Step 2:

        ldr     q4, lut1
        tbl     v3.16b, {v3.16b}, v4.16b

        umin    v0.4s, v2.4s, v3.4s
        umax    v1.4s, v2.4s, v3.4s
        ldr     q4, lut2
        tbl     v2.16b, {v0.16b, v1.16b}, v4.16b
        ldr     q4, lut3
        tbl     v3.16b, {v0.16b, v1.16b}, v4.16b

        // Step 3:

        umin    v0.4s, v2.4s, v3.4s
        umax    v1.4s, v2.4s, v3.4s

        // Step 4:

        ldr     q4, lut4
        tbl     v1.16b, {v1.16b}, v4.16b

        umin    v2.4s, v0.4s, v1.4s
        umax    v3.4s, v0.4s, v1.4s

        ldr     q4, lut5
        tbl     v0.16b, {v2.16b, v3.16b}, v4.16b
        ldr     q4, lut6
        tbl     v1.16b, {v2.16b, v3.16b}, v4.16b

        uminp   v2.4s, v0.4s, v1.4s
        umaxp   v3.4s, v0.4s, v1.4s

        ldr     q4, lut7
        tbl     v0.16b, {v2.16b, v3.16b}, v4.16b
        ldr     q4, lut8
        tbl     v1.16b, {v2.16b, v3.16b}, v4.16b

        umin    v2.4s, v0.4s, v1.4s
        umax    v3.4s, v0.4s, v1.4s

        // Merge results:

        ldr     q4, lut9
        tbl     v0.16b, {v2.16b, v3.16b}, v4.16b
        ldr     q4, lut10
        tbl     v1.16b, {v2.16b, v3.16b}, v4.16b

        str     q0, [sp]
        str     q1, [sp, #16]
        bl      printV

        leave                       // Return to caller.
        endp    asmMain

以下是构建命令和示例输出,适用于列表 11-1:

% ./build Listing11-1
% ./Listing11-1
Calling Listing11-1:
00000000 00000001 00000002 00000003 00000004 00000006 00000007 00000008
Listing11-1 terminated

如你所见,这段代码正确地对数据进行了排序。

11.12 使用 SIMD 代码的数字到十六进制字符串示例

列表 11-2 是一个 Neon 示例,你应该熟悉:第九章中的 dtoStr 函数,将 dword 转换为十六进制字符串。这是一个将现有代码转换为 SIMD 的实用示例。

// Listing11-2.S

`Usual source file information at the beginning of the file,`
`deleted for brevity`

// dtoStr
//
//  Converts the dword passed in X1 to 16
//  hexadecimal digits (stored into buffer pointed
//  at by X0; buffer must have at least 24 bytes
//  available)

    .equ    convert0toA, 'A' - ('0' + 10)    // val + '0' to val + 'A'
    .equ    invert0ToA,  ~convert0toA & 0xFF // Invert the bits for BIC.

    proc    dtoStr
    stp     q0, q1, [sp, #-32]!     // Preserve registers.

    rev     x1, x1                  // Reverse bytes (for output).
    mov     v0.d[0], x1             // Set V0 to the LO nibbles
    rev     x1, x1                  // and V1 to the HO nibbles,
    ushr    v1.8b, v0.8b, #4        // also, restore X1.
    bic     v0.4h, #0xf0
    bic     v0.4h, #0xf0, lsl #8

    zip1    v0.16b, v1.16b, v0.16b  // Interleave the HO and LO nibbles.

    orr     v0.8h, #0x30            // Convert binary to ASCII,
    orr     v0.8h, #0x30, lsl #8    // note only 0-9 will be correct.

    movi    v1.16b, #'9'            // Determine which bytes
    cmgt    v1.16b, v0.16b, v1.16b  // should be A-F.

    bic     v1.8h, #invert0ToA      // Update bytes that should be A-F.
    bic     v1.8h, #invert0ToA, lsl #8
    add     v0.16b, v0.16b, v1.16b

 str     q0, [x0]                // Output the string.
    strb    wzr, [x0, #16]

    ldp     q0, q1, [sp], #32       // Restore registers.
    ret
    endp    dtoStr

以下是构建命令和示例输出,适用于列表 11-2:

% ./build Listing11-2
% ./Listing11-2
Calling Listing11-2:
Value(fedcba9876543210) = string(FEDCBA9876543210)
Listing11-2 terminated

如果你对这段代码进行计时,你会发现它比第九章中的标量代码运行得明显更快。

11.13 SIMD 指令在实际程序中的应用

如果你已经读完本章,但不确定如何在实际程序中应用 SIMD 指令,不必觉得自己错过了什么。SIMD也可以代表“SIMD Instruction sets are Massively Difficult to use”(SIMD 指令集非常难用)。尽管 ARM 的 Neon 指令集比 Intel 的 SSE/AVX 扩展更具通用性,但 SIMD 指令的设计初衷是加速非常具体的算法的执行。我喜欢用这本书的技术审阅人 Tony Tribelli 的话来重新表述 SIMD 指令的适用性:“我看着一条特定的 SIMD 指令,问自己,‘这条指令是为哪个基准测试设计的?’”也就是说,SIMD 指令似乎是为了让某个基准测试程序运行得更快,从而让 ARM 的 CPU 看起来更好,虽然这条指令可能在该基准测试以外的地方并不有用。

从很多方面来看,这个说法是完全正确的:许多 SIMD 指令是为了解决一个特定的问题而创建的,它们在解决该问题之外的适用性几乎是偶然的。如果你无法弄清楚如何使用某个指令,可能是因为你还没有发现它最初设计用来解决的那个问题。

如果没有别的,向量寄存器的通道是存储临时值的好地方,尤其是在你已经使用了所有通用寄存器时。你可以使用 mov 指令在通用寄存器和向量寄存器的通道之间复制数据;这比将寄存器溢出到内存要快得多。

如果你真想使用 Neon 指令集进行高性能计算,参考下一页的 11.15 节,“更多信息”,或者在互联网上搜索“SIMD 并行算法”或“SIMD 向量算法”。

11.14 继续前进

这一长章节涵盖了许多指令内容。它从 SIMD 指令集的简短历史开始;讲解了 ARM 上的向量寄存器;讨论了 SIMD 数据类型、通道和标量操作;然后介绍了 Neon 指令集。本章最后通过一对简短的示例展示了如何使用向量寄存器进行比特排序和数值到十六进制字符串的转换。这些都是在 ARM 上使用 SIMD 指令的有用方式。尽管 SIMD 指令在一般程序中不常见,但只要稍加思考,你应该能在某些情况下利用它们加速代码。

剩下的几章将使用 SIMD 指令来提高性能。第十二章使用 Neon 指令来提升各种位操作的性能,第十四章使用 Neon 指令来实现快速的内存移动操作。你可以将本章学到的内容应用到之前章节中你学到的那些能从 SIMD 指令中获益的算法,比如数值到十六进制字符串的代码。我将留给你来实现这些改动。

11.15 更多信息

第十二章:12 位操作

在内存中操作比特可能是汇编语言最著名的特点。即使是以位操作著称的 C 编程语言,也没有提供如此完整的位操作指令集。

本章讨论如何使用 ARM 汇编语言操作内存和寄存器中的比特串。首先回顾到目前为止涵盖的位操作指令,引入一些新的指令,然后回顾内存中比特串的打包和解包信息,这为许多位操作提供了基础。最后,本章讨论了几种以位为中心的算法及其在汇编语言中的实现。

12.1 什么是比特数据?

位操作是指操作比特数据,这是一种由不连续或不是 8 位整数倍的比特串组成的数据类型。通常,这些比特对象不会表示数值整数,尽管我不会对比特串做出此限制。

比特串是由一个或多个比特组成的连续序列。它不一定要从特定的位置开始或结束。例如,比特串可以从内存中某字节的第 7 位开始,并延续到下一个字节的第 6 位。同样,比特串可以从 W0 寄存器的第 30 位开始,消耗 W0 的高 2 位,然后继续从 W1 寄存器的第 0 位到第 17 位。在内存中,比特必须是物理上连续的(也就是说,位编号总是递增,除非跨越字节边界,且字节边界处内存地址增加 1 字节)。在寄存器中,如果比特串跨越寄存器边界,应用程序定义继续使用的寄存器,但比特串总是从该第二个寄存器的第 0 位继续。

位运行是指所有位值相同的比特序列。0 的连续运行是包含所有 0 的比特串,而1 的连续运行是包含所有 1 的比特串。第一个置位比特是比特串中第一个包含 1 的位的位置——即,在可能存在的 0 连续运行后第一个 1 位。第一个清除位也有类似的定义。最后一个置位比特是比特串中最后一个包含 1 的比特位置;该位置之后的比特构成一个不间断的 0 连续运行。最后一个清除位也有类似的定义。

比特集是指在一个更大的数据结构内,可能不连续的一组比特。例如,双字中的第 0 到第 3 位、第 7 位、第 12 位、第 24 位和第 31 位构成一个比特集。通常,我们会处理作为容器对象(封装比特集的数据结构)一部分的比特集,容器的大小通常不超过 32 或 64 位,尽管这个限制是完全人为的。比特串是比特集的特殊情况。

位偏移 是从边界位置(通常是字节边界)到指定位的位数。如第二章所述,这些位从边界位置的 0 开始编号。

掩码 是一个位串,用来操作另一个值中的某些位。例如,位串 0b0000_1111_0000,当与 and 指令一起使用时,会掩蔽(清除)除了第 4 到 7 位之外的所有位。同样,如果你使用相同的值与 orr 指令一起使用,它可以设置目标操作数中的第 4 到 7 位。术语 掩码 源自这些位串与 and 指令的使用,其中 1 和 0 位就像画画时用的遮蔽胶带:它们让某些位保持不变,同时掩蔽掉(清除)其他位。

拥有这些定义后,你已准备好开始操作位了!

12.2 操作位的指令

让我们从回顾本书迄今为止介绍的操作位的指令开始,同时介绍一些额外的位操作指令。

位操作通常包括六个活动:设置位、清除位、反转位、测试和比较位、从位串中提取位以及将位插入到位串中。最基本的位操作指令有 and/ands bic、orr、orn、eor、mvn(非)、tst 以及移位和旋转指令。本节讨论这些指令,重点讲解如何使用它们在内存或寄存器中操作位。

12.2.1 隔离、清除和测试位

and/ands 指令提供了清除位序列中位的功能。此指令对于隔离一个与其他无关数据(或者至少是与位串或位集无关的数据)合并的位串或位集特别有用。例如,如果一个位串占用了 W0 寄存器中的第 12 到 24 位,你可以通过以下指令将 W0 中的其他所有位清零,从而隔离这个位串:

and w0, w0, 0b1111111111111000000000000

图 12-1 展示了此指令的结果。

图 12-1:使用 and 指令隔离位串

一旦你清除了位集中的不需要的位,通常可以就地对位集进行操作。例如,要检查 W0 中第 12 到 24 位的位串是否包含 0x2F3,可以使用以下代码:

mov w1, #0x2f3
lsl w1, w1,   #12 // Make it 0x2f3000.
and w0, w0,   #0b1111111111111000000000000
cmp w0, w1  // 0b0001011110011000000000000

你不能在 cmp 指令中使用立即数 0x2F3000,因此此代码首先将该常数加载到 W1 中,并将 W0 与 W1 进行比较。同样,你也不能使用 movz 将 0x2F3 左移 12 位后加载,因为 movz 只允许 0、16、32 或 48 位的移位;此外,0x2F3 也不是一个逻辑立即数模式,因此不能使用 mov 指令与 0x2F3000 一起使用。

注意

指令 mov w1, #logical_pattern 等同于 orr w1, wzr, #logical_pattern*。

为了使与此值一起使用的常数更容易处理,你可以使用 lsr 指令将位串对齐到第 0 位,方法是先屏蔽掉其他位,如下所示:

and w0, w0, #0b1111111111111000000000000
lsr w0, w0, #12
cmp w0, #0x2F3

普通的 and 指令不会影响任何条件码标志。如果你希望根据 AND 操作的结果更新 N 和 Z 标志,请使用 ands 变体,记住该指令会始终清除进位标志和溢出标志。

如果你希望将 ands 操作的结果捕获到 N 和 Z 标志中,但又不想保留逻辑结果,你可以使用 tst 指令。这相当于(并且是)使用 WZR 或 XZR 作为目标寄存器的 ands 指令(该操作会丢弃结果)。

别忘了你还可以使用 and 指令与向量寄存器一起使用(无论是向量操作还是标量操作);不过请记住,向量 and 指令不会影响标志位(没有 ands 变体)。

因为 tst 是 ands 的别名,并且没有向量 ands 指令,Neon 指令集提供了 cmtst 指令。

cmtst V`d.t`, V`d.t`, V`d.t`

其中 t 为 8B、16B、4H、8H、2S、4S 或 2D(8B、4H 和 2S 类型操作 Vn 的低 64 位,而其他类型则操作所有 128 位)。

该指令逻辑与(AND)Vd.t 中的每一条 lane 和 Vd.t 本身。如果结果不为 0,cmtst 会将目标 lane 设置为全 1;如果结果为 0,则将目标 lane 设置为全 0。你可以使用结果作为进一步向量(位)操作的位掩码。

12.2.2 设置和插入位

orr 指令对于将一个位集插入到另一个位串中尤其有用(无论是在通用寄存器还是向量寄存器中),可以通过以下步骤实现:

1.  清除源操作数中位集周围的所有位。

2.  清除目标操作数中你希望插入位集的所有位。

3.  将位集和目标操作数进行 OR 操作。

例如,假设你有一个值位于 W0 的第 0 到 11 位,你希望将其插入到 W1 的第 12 到 23 位中,而不影响 W1 中的其他位。你将首先从 W0 中清除第 12 位及更高位,然后从 W1 中清除第 12 到 23 位。接下来,你会将 W0 中的位向左移,使得位串占据 W0 的第 12 到 23 位。最后,你会使用 OR 操作将 W0 中的值与 W1 中的值合并,如下代码所示:

and     w0, w0, #0xFFF   // Strip all but bits 0 to 11 from W0.
bic     w1, w1, 0xFFF000 // Clear bits 12 to 23 in W1.
lsl     w0, w0, 12       // Move bits 0 through 11 to 12 through 23 in W0.
orr     w1, w1, w0       // Merge the bits into W1.

图 12-2 显示了这四条指令的结果。

图 12-2:将 W0 的第 0 到 11 位插入到 W1 的第 12 到 23 位中

图 12-2 中的步骤 1 清除了 W0 中的 U 位。清除 U 位后,步骤 2 屏蔽目标位字段(Y)。步骤 3 将 A 位(W0 中的第 0 到 11 位)左移 12 位,将它们与目标位字段对齐。步骤 4 将 W0 中的值与 W1 中的值进行 OR 操作,最终结果保留在 W1 中。

在图 12-2 中,所需的位(AAAAAAAAAAAA)形成了一个位串。然而,即使你操作的是一个非连续的位集合,这个算法仍然有效——你只需要创建一个在适当位置上有 1 的位掩码。

在处理位掩码时,使用字面数字常量是非常不好的编程风格,正如前面几个示例所示。到目前为止,我使用了“魔法数字”,因为示例很简单,使用字面常量在这种情况下更清晰。然而,你应该始终在 Gas 中创建符号常量。将这些常量与常量表达式结合使用,可以生成更易于阅读和维护的代码。之前的示例代码应该更恰当地写成以下形式:

StartPosn = 12;
BitMask   = 0xFFF << StartPosn  // Mask occupies bits 12 to 23.
   .
   .
   .
  lsl w0, w0, #StartPosn  // Move into position.
  and w0, w0, #BitMask    // Strip all but bits 12 to 23.
  and w1, w1, #~BitMask   // Clear bits 12 to 23 in W1.
  orr w1, w1, w0          // Merge the bits into W1.

使用编译时的 NOT 操作符(~)进行位掩码反转,可以避免在程序中创建另一个常量,该常量在修改 BitMask 常量时需要一起更改。维护两个相互依赖的符号不是一个好的实践。

当然,除了将一个位集与另一个合并外,orr 指令还可以用于将位强制设置为 1。在源操作数中将不同的位设置为 1,你可以使用 orr 指令将目标操作数中相应的位强制设置为 1。

你可以使用 orn(OR NOT)指令在目标中插入 1 位,只要源中有 0 位。在其他方面,此指令与 orr 行为相同。

如果 and w1, w1, #~BitMask 无法组装,因为 Gas 不接受立即数常量,请改用 bic w1, w1, #BitMask,如下一节所述。

12.2.3 清除位

而 orr 指令适用于设置寄存器中的位,bic 指令则用于清除它们。

bic  `Rd`, `Rl`, `Rr`  // `Rd` = `Rl` && ~`Rr`
bics `Rd`, `Rl`, `Rr`  // Also affects condition codes

其中 Rd、Rl 和 Rr 都是 Wn 或 Xn。bics 指令根据 Rd 中的值设置 N 和 Z 条件码。在 Rr 中出现 1 位的每个位置,bic 指令会清除 Rl 中相应的位(并将结果存储到 Rd 中)。

此指令的向量版本允许你在 64 位或 128 位向量寄存器中清除任意的位。

bic Vd.`t`, Vl.`t`, Vr.`t`

当 t = 8B 时为 64 位,t = 16B 时为 128 位。此指令还有一个立即数版本。

bic Vd.`t`, #imm8
bic Vd.`t`, #imm8, lsl #`shift`

当 t = 4H 时为 64 位,t = 8H 时为 128 位,或者 t = 2S 时为 64 位,t = 4S 时为 128 位。可选的移位值可以是 0 或 8 用于半字道,或者 0、8、16 或 24 用于字道。

12.2.4 翻转位

eor 指令允许你翻转位集(通用寄存器或向量寄存器)中选定的位。如果你想翻转目标操作数中的所有位,mvn(非)指令更合适;然而,要翻转选定的位而不影响其他位,eor 是更合适的选择。

eor 指令让你几乎可以用任何你能想象的方式操控已知数据。例如,如果你知道一个字段包含 0b1010,你可以通过与 0b1010 进行异或操作将该字段强制设置为 0。同样,你也可以通过与 0b0101 进行异或操作将其强制设置为 0b1111

尽管这看起来像是浪费,因为你可以通过使用 andorr 指令轻松将这个 4 位字符串强制设置为 0 或全 1,但 eor 指令有两个优点。首先,你不仅限于将字段强制设置为全 0 或全 1;你可以通过 eor 将这些位设置为 16 种有效组合中的任意一种。其次,如果你需要同时操作目标操作数中的其他位,andorr 可能无法完成这项工作。

例如,假设一个字段包含 0b1010,你希望将其强制设置为 0,而同一操作数中的另一个字段包含 0b1000,你希望将该字段递增 1(即设置为 0b1001)。你无法通过单一的 andorr 指令同时完成这两个操作,但你可以通过一条 eor 指令实现:只需将第一个字段与 0b1010 进行异或操作,将第二个字段与 0b0001 进行异或操作。然而,请注意,这个技巧仅在你知道目标操作数中某个位的当前值时才有效。

eon(异或非)与 eor 的工作原理相同,只不过它在目标操作数中,当右侧源操作数为 0 时,会反转目标操作数中的位。

12.2.5 移位与旋转

移位和旋转指令是另一组可以用来操控和测试位的指令。标准和 Neon 指令集提供了多种移位和旋转指令,允许你根据需要重新排列位数据:

asr    整数算术右移

lsl    整数逻辑左移

lsr    整数逻辑右移

ror    整数右旋

shl    向量或标量向左或向右移位

ushl, sshl, ushr, 和 sshr    向量或标量右移

sli    向左移位向量或标量并插入

sri    向量或标量右移并插入

除了这些通用的移位和旋转指令,还有一些专用变体,如饱和、舍入、狭窄和扩展。通常,这些专用指令对于位操作并不那么有用。

整数移位和旋转指令(那些作用于通用寄存器的指令)在将位移到最终位置时非常有用,尤其是在从多个源构造位字符串时。如你在第 2.12 节《位字段和打包数据》中看到的,在 第 85 页,你可以使用移位和旋转指令(配合逻辑指令)来打包和解包数据。

然而,移位和旋转指令的最大问题是它们没有提供设置条件码的选项。例如,在许多其他 CPU(包括 32 位 ARM 指令集)中,移位或旋转指令将最后移出寄存器的比特存储在进位标志中。这些指令通常根据最终结果设置 Z 和 N 标志。这对于许多操作非常方便,特别是那些使用循环处理寄存器中每个比特的操作;例如,你可以通过使用右移指令将比特从寄存器中移出,将输出比特捕获到进位标志(使用 bcc 或 bcs 测试该比特),并且还能测试零标志以检查寄存器中是否还有更多(设置)比特。这在 ARM 上是不可能的(通过单个移位或旋转指令)。

这个规则有一个例外。虽然移位不会影响进位标志,但 adds 指令会。使用像这样的指令:

adds w0, w0, w0

等同于对 W0 执行左移(1 位),设置所有条件码标志(包括将从第 31 位移出的位捕获到进位标志中)。adcs 指令的行为类似于“通过进位旋转 1 位”指令(请参见第 8.1.11.1 节,“左移”,在第 467 页)。由于第二章和第八章已经涵盖了使用移位指令插入和提取比特数据(打包字段),本章不再进一步讨论。

向量移位指令值得进一步讨论,因为它们包括有趣的变体(sli 和 sri),并且没有提供旋转指令。模拟向量左旋转需要三条指令:

// Simulate rol v1.4s, v1.4s, #4:

        ushr    v2.4s, v1.4s, #28      // HO 4 bits to 0:3
        shl     v1.4s, v1.4s, #4       // Bits 0:27 to 4:31
        orr     v1.16b, v1.16b, v2.16b // Merge in LO bits.

这个代码的最大问题是它需要一个额外的寄存器来保存临时结果。

以下是实现向量右旋转的指令:

// Simulate ror v1.4s, v1.4s, #4:

        shl    v2.4s, v1.4s, #28      // Bits 0:3 to 28:31
        ushr   v1.4s, v1.4s, #4       // Bits 4:31 to 0:27
        orr    v1.16b, v1.16b, v2.16b // Merge bits.

有关 sli 和 sri 指令的更多信息,请参阅第 11.6.4 节,“移位与插入”,在第 652 页。

由于向量移位指令仅作用于通道,因此无法通过单个指令直接对所有 128 位的向量寄存器进行移位。然而,通过五条指令,你可以完成它:

// Simulate shl for 128 bits:

        ushr    v2.2d, v1.2d, #60      // Save bits 60:63.
        mov     v2.b[8], v2.b[0]       // Move into HO dword.
        mov     v2.b[0],  wzr          // No mask at original
        Shl     v1.4s, v1.4s, #4       // Shift bits left.
        orr     v1.16b, v1.16b, v2.16b // Merge bits 60:63.

我将留给你去执行右移操作,它只是对 shl 代码的简单修改。

12.2.6 条件指令

csel、csinc/cinc、csinv/cinv、cset 和 csetm 指令对于操作比特也非常有用。特别是,cset 和 csetm 可以根据条件码帮助你初始化寄存器为 0、1 或全 1 比特。所有这些指令对于处理已设置条件码的操作非常有用(请参见第 12.3 节,“算术与逻辑指令对标志的修改”,在第 715 页)。这些条件指令没有向量等效指令。

12.2.7 计数比特

cls 和 clz 指令允许你计算前导 1 位和 0 位(前导指从 HO 位位置到 LO 位位置)。cnt 指令(人口计数)计算(向量)寄存器中字节通道中的所有已设置位。这些指令的语法如下:

cls `Rd`, `Rs`
clz `Rd`, `Rs`
cls V`d.t1`, V`s.t1`
clz V`d.t1`, V`s.t1`
cnt V`d.t2`, V`s.t2`

其中 Rd 和 Rs 是 Wn 或 Xn;t1 是 8B、16B、4H、8H、2S 或 4S;t2 是 8B 或 16B。对于向量指令,如果操作数是 8B、4H 或 2S,指令仅在向量寄存器的 LO 64 位上操作。

cls 指令(通用寄存器形式)计算 Rs 中前导符号位的数量,并将该计数存储到 Rd 中。该指令计算与 HO 位匹配的位数(HO 位以下的位)。请注意,符号(HO)位不包括在计数中。

clz 指令的作用相同,但会计算包括符号位在内的前导 0 位的数量。要计算实际的前导 1 位数量(包括 HO 符号位),请反转源寄存器的值并使用 clz 指令计算 0 位。

cls 和 clz 指令的向量版本以与标量版本相同的方式计算源寄存器中每个通道的前导 1 或 0 位的数量,并将此计数存储到目标寄存器的相应通道中。

cnt 指令计算源寄存器中每个字节(通道)中已设置位的数量,并将该位数存储到目标寄存器的相应通道中。要查找向量寄存器(64 位或 128 位)的总人口(位)计数,请使用 addv 指令将目标寄存器中的所有字节求和。要计算 hwords、words 或 dwords 的人口计数,请使用 addp 指令将字节对加起来(以生成 hword 计数)。你可以使用第二个 addp 指令(同样是在字节对上,生成字节结果数组)来计数 hwords 对,生成 word 计数,依此类推。

12.2.8 位反转

rbit 指令会反转源操作数中的位,并将反转后的结果存储到目标操作数中。其语法如下:

rbit W`d`, W`s`
rbit X`d`, X`s`
rbit V`d.t`, V`s.t` // `t` = 8B or 16B

对于 32 位寄存器操作数,该指令交换位置 0 和 31 的位,1 和 30 的位,2 和 29 的位,...,以及 15 和 16 的位。对于 64 位操作数,rbit 会交换位置 0 和 63 的位,1 和 62 的位,2 和 61 的位,...,以及 31 和 32 的位。对于向量操作数,它会反转源向量寄存器中每个字节通道的位,并将结果存储到目标向量寄存器的相应字节通道中。

该指令的向量变体仅反转字节通道中的位。要反转 16 位、32 位或 64 位对象中的位,还需要执行 8 位通道的 rev16、rev32 或 rev64 指令(在 rbit 之前或之后)。例如,以下两个指令会反转 64 位向量寄存器中的所有位:

rbit  v1.16b, v1.16b  // Do a 2D bit reversal; first bits,
rev64 v1.16b, v1.16b  // then bytes.

你可以使用类似的代码(使用 rev32)来反转向量寄存器中两个双字的位。

12.2.9 位插入与选择

bif(如果为假则插入位)、bit(如果为真则插入位)和 bsl(位选择)指令允许你操作向量寄存器中的单个位。这些指令的语法如下:

bif V`d.t`, V`s.t`, V`m.t`    // `t` = 8B (64 bits) or 16B (128 bits)
bit V`d.t`, V`s.t`, V`m.t`    // V`d` = dest, V`s` = source, V`m` = mask
bsl V`d.t`, V`s1.t`, V`s0.t`

bif 指令首先考虑掩码寄存器;Vm 中出现 0 位的地方,指令将对应的位从 Vs(源)复制到 Vd(目标)的相同位置。Vm 中出现 1 位的地方,bif 指令保持 Vd 中对应的位不变。

bit 指令与 bif 指令相同,唯一不同的是它在相反的条件下复制位(当 Vm 中为 1 时)。

bsl 指令使用 Vd 中的(原始)位来选择 Vs1 或 Vs0 中对应的位。Vd 中出现 1 的位置,bsl 将从 Vs1 中复制相应的位到 Vd;Vd 中出现 0 的位置,bsl 将从 Vs0 中复制相应的位到 Vd。

12.2.10 使用 ubfx 的位提取

ubfx 指令允许你从源寄存器中的某一位置提取任意数量的位,并将这些位移至目标寄存器的第 0 位。语法为:

ubfx `Rd`, `Rs`, #`lsb`, #`len`

其中 Rd 和 Rs 都可以是 Wn 或 Xn,lsb 是提取的起始位位置,len 是要提取的位串大小。lsb 和 len 的总和不得超过寄存器的大小。

ubfx 指令从 Rs 中提取 len 位,从 lsb 位开始。它将该位串存储到 Rd(从位 0 开始),并将 Rd 的高位清零。例如:

ubfx x0, x1, #8, #16

将 X1 中的第 8 到 23 位复制到 X0 中的第 0 到 15 位(并将 X0 中的第 16 到 63 位清零)。

12.2.11 使用 ubfiz 的位移动

ubfiz(无符号位域插入零)将来自源寄存器的低位复制到目标寄存器的任何位置,作为 ubfx 指令的反向操作。该指令的语法为:

ubfiz `Rd`, `Rs`, #`posn`, #`len`

其中 Rd 和 Rs 都可以是 Wn 或 Xn,posn 是目标位置,Rs 中的第 0 位将被移动到该位置,len 是位串的大小。例如:

ubfiz w1, w0, #12, #8

将 W0 中的第 0 到 7 位复制到 W1 中的第 12 到 19 位。

12.2.12 使用 ubfm 的位移动

ubfm 指令(无符号位域移动)将源寄存器中的低位复制到目标寄存器中的任意位置(并将目标寄存器中的其他位置填充为 0)。该指令的语法为:

ubfm `Rd`, `Rs`, #`immr`, #`imms`

其中 Rd 和 Rs 都可以是 Wn 或 Xn,immr 和 imms 的值范围是 32 位操作为 0 到 31,64 位操作为 0 到 63。此指令根据 immr 和 imms 的值执行以下两种操作之一:

  • 如果 immr ≤ imms,则取 immr 到 imms 之间的位,并向右旋转 immr 位。

  • 如果 immr > imms,则取 imms + 1 个低位并向右旋转 immr 位。

ubfm 指令是许多 ARM 指令集中的基础指令(别名):lsl Rd, Rs, #shift 等价于

ubfm `Rd`, `Rs`, #(`Rsize` - `shift`) % `Rsize`, #`Rsize` - 1 - `shift`

其中 Rsize 是寄存器的大小(32 位或 64 位)。

与此同时,lsr Rd, Rs, #shift 等价于

ubfm `Rd`, `Rs`, #`shift`, #`Rsize` - 1 // `Rsize` is register size.

并且 ubfiz Rd, Rs, #lsb, #width 等价于

ubfm `Rd`, `Rs`, #(`Rsize`-`lsb`) % `Rsize`, #`width` - 1

其中 Rsize 是寄存器的大小(32 或 64)。最后,ubfx Rd, Rs, #lsb, #width 等价于

ubfm `Rd`, `Rs`, #`lsb`, #`lsb` + `width` - 1

其中 lsb 是要移动的位串的 LO 位,width 是要移动的位数。

12.2.13 使用 extr 进行位提取

extr 指令允许你从一对寄存器中提取一个位串,语法为

extr `Rd`, `Rl`, `Rr`, #`posn`

其中 Rd、Rl 和 Rr 都是 Wn 或 Xn,而 posn 是一个常数,范围从 0 到寄存器大小减 1(31 或 63)。

该指令首先将 Rl 和 Rr 寄存器连接起来,形成一个 64 位或 128 位的位串。然后,它从该位串中提取 32 或 64 位(具体取决于寄存器大小),从位位置 posn 开始。

注意

ror(立即数)指令是extr的别名,获取方法是将RlRr设置为相同的寄存器。

12.2.14 使用 tbz 和 tbnz 进行位测试

tbz(测试位零)和 tbnz(测试位非零)指令允许你根据寄存器中是否设置了特定的位来分支到某个位置,使用以下语法:

tbz `Rs`, #`imm`, `target` // R`s` = W`n` or X`n`, `imm` = 0 to 31 (W`n`) or
tbnz `Rs`, #`imm`, `target` // 0 to 63 (X`n`). `target` is a stmt label.

这些指令测试 Rs 中由 imm 指定的位,看它是 0 还是 1。tbz 指令会将控制转移到指定的目标标签(如果位为 0 时),而 tbnz 则在位为 1 时将控制转移到指定目标标签(如果位为 0 时则继续执行)。

你可以使用这些指令将任何寄存器变成一个 32 位或 64 位的“伪条件码寄存器”,使你可以根据该寄存器中特定位是否被设置或清除来进行分支。尽管没有指令会自动设置或清除这些“条件码”,你可以使用本章中的任何位操作指令来操作这些伪条件码。

别忘了你还可以使用 cbz(如果为零则比较并分支)和 cbnz(如果不为零则比较并分支)指令,将寄存器与 0 进行比较,并在其等于 0 时(cbz)或不等于 0 时(cbnz)转移控制。这在 addv、orr 或其他不设置 Z 标志的指令之后特别有用,用来查看它们是否产生了零(或非零)结果。

12.3 算术和逻辑指令对标志的修改

在前面的章节中,指令操作了通用寄存器和向量寄存器中的位。尽管 PSR 不是通用寄存器,但请记住,ands、bics 和 tst 指令会根据计算结果设置 N 和 Z 标志。如果结果的 HO(符号)位为 1,这些指令会设置 N 标志;如果结果为 0,这些指令会设置 Z 标志。否则,它们会清除这些标志。

你还可以使用 adds、adcs、subs、sbcs、negs、ngcs、cmp、ccmp 和 ccmn 指令来设置标志。特别要记住的是

adds `Rd`, `Rn`, `Rn` // `R` = X or W

将 Rn 中的值向左移动一位,并将(原始的)HO 位移入进位标志。如果两个原始的 HO 位包含 0b01 或 0b10,则该指令会设置溢出标志。如果结果的 HO 位是 1,则会设置负数标志。最后,如果加法结果为 0,当然会设置零标志。

另外请注意,指令

adcs `Rn`, `Rn`, `Rn` // `R` = X or W

等效于“将 Rn 向左旋转 1 位通过进位标志”指令。您可以使用此指令根据 Rn 的 HO 位设置进位标志,并将先前的进位标志值捕获到 Rn 的 LO 位。

请记住,只有对通用寄存器进行操作的算术和逻辑指令(并且带有 s 后缀)才会影响标志位。特别地,向量指令不会影响标志位。

尽管它不是算术或逻辑指令,但 mrs(将寄存器值移入状态寄存器)指令,通过目标字段 NZCV,将所有标志设置为通用寄存器第 28 到第 31 位的值。这提供了一种快速的方式,通过 4 位创建多重分支。考虑以下代码(取自第 11.10.4 节,“向量比较结果”,见 第 691 页):

 lea     r3, mask            // 0x0008000400020001
        ldr     q3, [r3]
        cmeq    v0.4h, v1.4h, v2.4h
        and     v0.8b, v0.8b, v3.8b // Keep LO bit of each lane.
        addv    h0, v0.4h           // Merge the bits into H0.
        umov    w0, v0.h[0]
        lsl     w0, w0, #28
        mrs     x0, nzcv

现在,您可以测试 N、Z、C 和 V 标志,看看 V1 中的第 3、2、1 或 0 号通道是否与 V2 中对应的通道相等。

本节提供了一个通用的介绍,说明如何设置条件码标志以捕获位值。由于带有 s 后缀的指令对标志位的影响不同,因此讨论指令如何影响各个条件码标志非常重要;接下来的章节将处理这一任务。

12.3.1 零标志

零标志(Z)设置是 ands 指令生成的最重要结果之一。事实上,程序在 ands 指令执行后非常频繁地引用此标志,因此 ARM 添加了一个单独的 tst 指令,其主要目的是逻辑与两个结果并设置标志,而不会影响任何指令的操作数。

注意

从技术上讲, tst 并不是一条新指令,而是 ands 的别名,当目标寄存器为 WZR 或 XZR 时。

零标志可用于检查执行 ands 或 tst 指令后的三件事:操作数中的某一特定位是否被设置,多个位集合中是否至少有一个位为 1,以及操作数是否为 0。第一种用法实际上是第二种用法的一个特例,其中位集合仅包含一个位。接下来的段落将探讨这些用途的每一种。

要测试某一特定的位是否在给定操作数中被设置,可以使用 andstst 指令将操作数与包含单个设置位的常数值进行按位与运算。这样可以清除操作数中的所有其他位,如果操作数中该位位置为 0,则测试位的结果为 0,如果为 1,则结果为 1。因为结果中的所有其他位都是 0,所以如果该特定位为 0,则整个结果为 0;如果该位为 1,则结果为非零。ARM CPU 会在零标志位(Z = 1 表示位为 0;Z = 0 表示位为 1)中反映这一状态。以下指令序列演示了如何测试 W0 中的第 4 位是否被设置:

 tst w0, 0b10000  // Check bit #4 to see if it is 0/1.
  bne bitIsSet

 `Do this if the bit is clear.`
   .
   .
   .
bitIsSet:   // Branch here if the bit is set.

你还可以使用 andstst 指令来检查多个位中的任何一个是否被设置。只需提供一个常数,该常数在你想要测试的所有位置上为 1,其他位置为 0。将操作数与这种常数进行按位与运算,如果操作数中被测试的位包含 1,则结果为非零值。以下示例测试 W0 中的值在第 1 位和第 2 位是否包含 1:

 tst w0, 0b0110
    beq noBitsSet

 `Do whatever needs to be done if one of the bits is set.`

noBitsSet:

你不能仅使用一条 andstst 指令来检查位集中的所有对应位是否都等于 1。要实现这一点,必须先屏蔽掉不在位集中的位,然后将结果与掩码本身进行比较。如果结果等于掩码,则位集中的所有位都包含 1。你必须使用 ands 指令来完成此操作,因为 tst 指令不会修改结果。以下示例检查位集(bitMask)中的所有位是否都等于 1:

 ldr  w1, =bitMask     // Assume not valid immediate const.
    ands w0, w0, w1
    cmp  w0, w1
    bne  allBitsArentSet

// All the bit positions in W0 corresponding to the set
// bits in bitMask are equal to 1 if we get here.

 `Do whatever needs to be done if the bits match.`

allBitsArentSet:

当然,一旦你在其中使用了 cmp 指令,就不需要检查位集中所有位是否都是 1。你可以通过指定适当的值作为 cmp 的操作数来检查任何组合的值。

只有当 W0(或其他目标操作数)中在常数操作数中为 1 的位置上的所有位都为 0 时,tstands 指令才会设置零标志。这表明另一种检查位集中的所有位是否为 1 的方法:在使用 andstst 指令之前,先对 W0 的值进行取反。在这种情况下,如果零标志被设置,就知道(原始的)位集包含了所有的 1。例如,以下代码检查位掩码中由 1 指定的任何位是否非零:

 ldr  w1, =bitMask     // Assume not valid immediate const.
    mvn  w0, w0
    tst  w0, w1
    bne  NotAllOnes

// At this point, W0 contained all 1s in the bit positions
// occupied by 1s in the bitMask constant.

 `Do whatever needs to be done at this point.`

NotAllOnes:

前面的段落都建议位掩码(源操作数)是常数,但你也可以使用变量或其他寄存器。只需在执行前面的 tstandscmp 指令之前,将该变量或寄存器加载适当的位掩码。

12.3.2 负标志

tst 和 ands 指令如果结果的 HO 位被设置,还会设置负数标志(N,也称为符号标志)。这允许你测试寄存器中的两个独立位,假设其中一个是 HO 位。使用这些指令时,掩码值必须在 HO 位和你想要测试的另一个位位置上都包含 1。执行 tst 或 ands 指令后,你必须先检查 N 标志,再测试 Z 标志(因为如果 HO 位被设置,Z 标志会被清除)。

如果 HO 位被设置,并且你还想查看另一个位是否被设置,你必须再次测试该位(或者使用 cmp 指令,如前一节所述)。

12.3.3 进位标志和溢出标志

影响标志的逻辑指令(ands 和 tst)会清除进位标志和溢出标志。然而,算术指令(adds、adcs、subs、sbcs、negs、ngcs、cmp、ccmp 和 ccmn)会修改这些标志。特别地,当两个源操作数相同时,adds 和 adcs 会将(原始)源的 HO 位移入进位标志。

对最小负值(例如,字值 0x80000000)取反将设置溢出标志:

orr     w1, wzr, #0x80000000  // mov x1, #0x80000000
negs    w1, w1                // Sets V (and N) flags

请参阅 ARM AARCH64 文档,以确定各种指令如何影响进位标志和溢出标志。请注意,许多指令不会影响这两个标志(特别是溢出标志),即使它们会影响 N 标志和 Z 标志。

12.4 打包和解包位字符串

将位字符串插入操作数中或从操作数中提取位字符串是常见操作。第二章提供了简单的打包和解包数据的例子;这一节正式描述了如何执行这些操作,现在你已经学会了更多指令并拥有更多工具。

12.4.1 将一个位字符串插入另一个位字符串

本章的讨论假设我们处理的是适合于字节、半字、字或双字操作数的位字符串。跨越对象边界的大型位字符串需要额外的处理;我将在本节稍后讨论跨越双字边界的位字符串。

在打包和解包位字符串时,你必须考虑它的起始位位置和长度。起始位位置是字符串在操作数中的 LO 位的位号。长度是字符串中的位数。

要将数据插入(打包)到目标操作数中,首先使用一个合适长度的位字符串,它是右对齐的(从位位置 0 开始),并且扩展为 8、16、32 或 64 位。接下来,将此数据插入到另一个宽度为 8、16、32 或 64 位的操作数中的适当起始位置。目标位位置的值没有保证。

前两步(可以按任意顺序进行)是清除目标操作数中对应的位,并将位串的副本移位,使 LO 位开始于适当的位位置。第三步是将移位结果与目标操作数做 OR 操作。这将把位串插入到目标操作数中。图 12-3 描述了这一过程。

图 12-3:将位串插入目标操作数

以下三条指令将已知长度的位串插入目标操作数,如 图 12-3 所示。这些指令假设源位串位于 W1(在位串外的位为 0),目标操作数位于 W0:

lsl  w1, w1, #5
bic  w0, w0, #0b111100000
orr  w0, w0, w1

对于目标位位置和位串长度为常量(在汇编时已知)的特殊情况,ARM CPU 提供了一条指令来处理位插入:bfi(位域插入)。其语法如下:

bfi `Rd`, `Rs`, #`posn`, #`len`

其中 Rd 和 Rs 都可以是 Wn 或 Xn。posn 和 len 的和不得超过寄存器的大小(Wn 为 32,Xn 为 64)。

bfi 指令将 Rs 的 LO len 位插入到目标寄存器(Rd)中,从位位置 posn 开始。考虑以下指令:

bfi w0, w1, #12, #16

假设 W0 包含 0x33333333(目标值),W1 包含 0x1200(插入值)。这将使 W0 中的值变为 0x31200333。

如果在编写程序时不知道长度和起始位置(即你需要在运行时计算它们),则必须使用多条指令来进行位串插入。假设你有两个值——一个是要插入字段的起始位位置,另一个是非零的长度值——并且源操作数位于 W1,目标操作数位于 W0。列表 12-1 中的 mergeBits 过程演示了如何将位串从 W1 插入到 W0。

// Listing12-1.S
//
// Demonstrate inserting bit strings into a register.
//
// Note that this program must be assembled and linked
// with the "LARGEADDRESSAWARE:NO" option.

#include    "aoaa.inc"

            .text
            .pool

ttlStr:     wastr   "Listing 12-1"

// Sample input data for the main program:

Value2Merge:
            .dword  0x12, 0x1e, 0x5555
            .dword  0x1200, 0x120

MergeInto:
            .dword  0xffffffff, 0, 0x12345678
            .dword  0x33333333, 0xf0f0f0f

LenInBits:  .dword  5, 9, 16, 16, 12
szLenInBits =       (.-LenInBits)/8

StartPosn:  .dword  7, 4, 4, 12, 18

// Format strings used to print results:

fmtstr1:    wastr   "merge(%x, "
fmtstr2:    wastr   "%x, "
fmtstr3:    wastr   "%d) = "
fmtstr4:    wastr   "%x\n"
fmtstr:     wastr   "Here I am!\n"

// getTitle
//
// Returns a pointer to the program's name
// in X0:

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

// MergeBits(Val2Merge, MergeWith, Start, Length)
//
// Length (LenInBits[i]) value is passed in X3.
// Start (StartPosn[i]) is passed in X2.
// Val2Merge (Value2Merge[i]) and MergeWith (MergeInto[i])
// are passed in X1 and X0.
//
// mergeBits result is returned in X0.

            proc    mergeBits

            locals  mb
            qword   mb.x1x2
            qword   mb.x3x4
            byte    mb.stk, 64
            endl    mb

            enter   mb.size

            stp     x1, x2, [fp, #mb.x1x2]
            stp     x3, x4, [fp, #mb.x3x4]

 // Generate mask bits
            // 1 in bits 0 to n - 1:

          ❶ mov     x4, #1
            lsl     x4, x4, x3  // Compute 2**n.
            sub     x4, x4, #1  // 2**n - 1

            // Position mask bits to target location:

          ❷ lsl     x4, x4, x2

            // Mask out target bits:

          ❸ bic     x0, x0, x4

            // Merge the bits:

          ❹ lsl     x1, x1, x2
            orr     x0, x0, x1

            // Restore registers and return:

            ldp     x3, x4, [fp, #mb.x3x4]
            ldp     x1, x2, [fp, #mb.x1x2]
            leave
            endp    mergeBits

// Here is the asmMain function:

          ❺ proc    asmMain, public

            locals  am
            qword   am.x20x21
            qword   am.x22x23
            dword   am.x24
            byte    am.stk, 256
            endl    am

            enter   am.size
            stp     x20, x21, [fp, #am.x20x21]
            stp     x22, x23, [fp, #am.x22x23]
            str     x24, [fp, #am.x24]

            // The following loop calls mergeBits as
            // follows
            //
            // mergeBits
            // (
            //      Value2Merg[i],
            //      MergeInto[i],
            //      StartPosn[i],
            //      LenInBits[i]);
            //
            // where "i" runs from 4 down to 0.
 //
            // Index of the last element in the arrays:

            mov     x20, #szLenInBits - 1

testLoop: 

            // Fetch the Value2Merge element and write
            // its value to the display while it is
            // handy:

            lea     x1, Value2Merge
            ldr     x1, [x1, x20, lsl #3]
            mstr    x1, [sp]
            lea     x0, fmtstr1
            mov     x22, x1             // Save for later.
            bl      printf

            // Fetch the MergeInto element and write
            // its value to the display:

            lea     x1, MergeInto
            ldr     x1, [x1, x20, lsl #3]
            mstr    x1, [sp]
            mov     x21, x1             // Save for later.
            lea     x0, fmtstr2
            bl      printf

            // Fetch the StartPosn element and write
            // its value to the display:

            lea     x1, StartPosn
            ldr     x1, [x1, x20, lsl #3]
            mstr    x1, [sp]
            mov     x23, x1             // Save for later.
            lea     x0, fmtstr2
            bl      printf

            // Fetch the LenInBits element and write
            // its value to the display:

            lea     x1, LenInBits
            ldr     x1, [x1, x20, lsl #3]
            mstr    x1, [sp]
            mov     x24, x1             // Save for later.
            lea     x0, fmtstr3
            bl      printf

            // Call MergeBits:
            //      (
            //          Value2Merge,
            //          MergeInto,
            //          StartPosn,
            //          LenInBits
            //      );

 mov     x0, x21
            mov     x1, x22
            mov     x2, x23
            mov     x3, x24
            bl      mergeBits

            // Display the function result (returned in
            // X0\. For this program, the results are
            // always 32 bits, so it prints only the LO
            // 32 bits of X0):

            mov     x1, x0
            mstr    x1, [sp]
            lea     x0, fmtstr4
            bl      printf

            // Repeat for each element of the array:

            subs    x20, x20, #1
            bpl     testLoop

allDone:
            ldp     x20, x21, [fp, #am.x20x21]
            ldp     x22, x23, [fp, #am.x22x23]
            ldr     x24, [fp, #am.x24]
            leave
            endp    asmMain

mergeBits 函数是执行合并操作的地方。此代码首先生成一个掩码,掩码包含从位置 0 到 n – 1 的所有 1 位,其中 n 是要插入的位串的长度 ❶。代码使用一个简单的数学技巧生成这些位:如果计算 2^n 并从该值中减去 1,结果值包含从 0 到 n – 1 位置的 1 位。生成此掩码后,代码将掩码位移到 mergeBits 将插入位串的位置 ❷。然后,它将目标位置中的这些位进行掩码操作(设置为 0)❸。

为了完成合并,mergeBits 将要合并的位移到正确的位置,并将这些位与目标位置(此时该位置包含 0)进行 OR 操作 ❹。mergeBits 函数假定源位(即要合并的位)形成一个正好长 n 位的位串(n 是传递给 X3 的值),并位于 0 到 n - 1 的位位置。请注意,如果你需要处理可能在 n 或更大位置有 1 位的位插入值,你应该在将其与位掩码进行逻辑与运算后再移位 ❹。第 12-1 列表中的 mergeBits 版本假定 val2Merge 参数(X1)不包含额外的 1 位。

asmMain 函数 ❺ 是一个循环,遍历 ValueToMerge、MergeInto、LenInBits 和 StartPosn 数组。该循环获取这四个值,打印它们,然后调用 mergeBits 函数将 ValueToMerge 的条目合并到 MergeInto 中。LenInBits 元素包含要合并的大小(以位为单位),而来自 StartPosn 数组的值是合并应发生的位置。

这是第 12-1 列表的构建命令和示例输出:

% ./build Listing12-1
% ./Listing12-1
CallingListing 12-1:
merge(120, f0f0f0f, 12, 12) = 4830f0f
merge(1200, 33333333, c, 16) = 31200333
merge(5555, 12345678, 4, 16) = 12355558
merge(1e, 0, 4, 9) = 1e0
merge(12, ffffffff, 7, 5) = fffff97f
Listing12-1 terminated

mergeBits 函数非常通用,允许你将位串长度和目标位置指定为可变参数。如果位串长度和目标位置在代码中是常量(这是一种常见的特殊情况),你可以使用更高效的方法将一个寄存器的位插入另一个寄存器:bfm(位域移动)指令。该指令的语法如下:

bfm `Rd`, `Rs`, #`rotate`, #`bitposn`

其中 Rd 和 Rs 都可以是 Wn 或 Xn,rotate 是旋转右移的位数,bitposn 是源中要移动的最左边的位(从位 0 开始)。

该指令将 Rs 中 LO 位的位置的位复制旋转指定数量的位位置,然后将这些旋转后的位替换到 Rd 中相应的位置。

注意

bfi 指令是 bfm 的别名,区别在于两个立即数操作数的含义略有不同(更多细节请参考 ARM 文档)。

本节中的示例假设位串完全出现在一个双字(或更小)对象中。如果位串的长度小于或等于 64 位,这种情况总是成立。然而,如果位串的长度加上它在对象中起始位置的值(模 8)大于 64,位串将跨越对象内的双字边界。

插入这样的位串需要最多三步操作:一是提取位串的起始部分(直到第一个双字边界),二是复制完整的双字(假设位串非常长,跨越多个双字),三是复制位串最后一个双字中的剩余位。我将把这个操作的实现留给你自己去完成。

12.4.2 提取位串

前一章节描述了如何将一个位串插入到另一个位串中。本节讨论相反的操作:从一个较大的位串中提取出一个位串。

bfxil(低端位提取和插入)指令从源寄存器提取任意数量的位(可以是任意位置的位),并将这些位复制到目标寄存器的低位位置。其语法为:

bfxil `Rd`, `Rs`, #`posn`, #`len`

其中 Rd 和 Rs 是 Wn 或 Xn。posn 和 len 的和不得超过寄存器大小(对于 Wn 为 32,Xn 为 64),且 posn 必须小于寄存器大小。

该指令从 Rs 中提取从 posn 位开始的 len 个位,并将它们插入到 Rd 的低位 len 个位置中。它不会影响 Rd 中其他位(posn 位及更高位)。通常,你会在使用此指令之前将 Rd 设置为 0,如下例所示:

mov   w0, wzr         // Extract bits 5 through 12 from W1
bfxil w0, w1, #5, #8  // and store them in W0.

和 bfi 指令类似,bfxil 仅支持对 posn 和 len 操作数使用立即数。如果你需要为其中一个(或两个)参数指定变量,则必须编写一个 extractBits 函数(类似于前面章节中的 mergeBits)。以下指令执行 extractBits 中的实际位提取:

// Generate mask bits
// 1 in bits 0 to n - 1:

mov     x4, #1
lsl     x4, x4, x3  // Compute 2**n.
sub     x4, x4, #1  // 2**n - 1

// Position mask bits to target location:

lsl     x4, x4, x2

// Extract the target bits:

and     x1, x1, x4

// Right-justify the bits to bit 0:

lsr     x0, x1, x2

这样会将提取的位保留在 X0 的低位位置。

12.4.3 清除位域

Gas 汇编器提供了 bfm 指令的别名,你可以用它来清除寄存器中的位:bfc(位域清除)。其语法为:

bfc `Rd`, #`posn`, #`len`

其中 Rd 是 Wn 或 Xn,posn 和 len 的含义与 bfi 指令相同,并具有相同的限制。如果你提供 len 字段为 1,可以使用 bfc 清除单个位(由位号指定)。

bfc 指令将 Rd 中从 posn 位开始的 len 个位清零。它等价于以下指令:

bfi `Rd`, `Rzr`, #`posn`, #`len`  // Rzr = WZR or XZR

其中,Rd 是 Wd 或 Xd,视情况而定。

bfc 指令仅在 ARMv8.2-a 及更高版本的 CPU 上可用,不适用于 Raspberry Pi(3 和 4 版)及其他低端系统。(注意,Raspberry Pi 5 支持该指令。)

12.4.4 使用 bfm

bfxil 和 bfi(以及 bfc)指令实际上是 bfm 指令的别名:

bfm `Rd`, `Rs`, #`immr`, #`imms`

和 ubfm 指令一样,它们基于 immr 和 imms 的值执行两项操作:

  • 如果 immr ≤ imms,从 Rs 中取出 immr 到 imms 位的值,并将其右移 immr 位,与 Rd 中现有的位进行合并。

  • 如果 immr > imms,从 Rs 中取出 imms + 1 个低位,并将其右移 immr 位,与 Rd 中现有的位合并。

例如:

ldr     w0, =0xffffffff
mov     w1, #0x2
bfm     w0, w1, #4, #2

生成 W0 中的 0xAFFFFFFF。

bfi 指令等价于以下内容:

bfm `Rd`, `Rs`, #(-`posn` % 64), #(len-1)

bfxil 指令等价于以下内容:

bfm `Rd`, `Rs`, #`posn`, #(`len`+`posn`-1)

通常,你会使用这些别名而不是 bfm 助记符。

12.5 常见的位操作

在汇编语言程序中,你会遇到许多位操作设计模式。本节介绍了一些常见的算法和模式。

12.5.1 合并位集合与分配位串

插入和提取位集与插入和提取位串只有一点不同,那就是插入的位集(或提取的结果位集)的“形状”必须与主对象中的位集形状相同。位集的形状是指位集中文位分布的方式,忽略位集的起始位置。例如,一个包含位 0、4、5、6 和 7 的位集与一个包含位 12、16、17、18 和 19 的位集具有相同的形状,因为它们的位分布是一样的。

插入或提取该位集的代码与前面部分几乎相同;唯一的区别是你使用的掩码值。例如,要将从 W0 的位 0 开始的位集插入到 W1 中从位置 12 开始的对应位集,你可以使用以下代码:

ldr w2, =0b11110001000000000000 // Bit set mask in posn.
lsl w0, w0, #12                 // Move src bits into posn.
and w0, w0, w2                  // Mask out source bits.
bic w1, w1, w2                  // Clear out destination bits.
orr w1, w1, w0                  // Merge bit set into W1.

然而,假设你有五个位在 W0 中的位 0 到 4,并且希望将它们合并到 W1 中的位 12、16、17、18 和 19 上。你必须以某种方式分配 W0 中的这些位,才能在逻辑 OR 运算后将它们合并到 W1;也就是说,你必须将 W0 中位 0 到 4 的位移动到 W1 中位 12、16、17、18 和 19 的位置。

相反操作,合并位,从各个位位置提取位并将其打包(合并)到目标位置的 LO 位中。以下代码演示了如何根据位掩码中的值分配位于位串中的位:

// W0- Contains the source value to insert the bits into
// W1- Contains the bits to insert, justified against bit 0
// W2- Counter (size of register, 32 in this case)
// W3- Bitmap; 1s specify bits to copy, 0 specifies bits
//      to preserve

            mov     w2, #32      // Number of bits to rotate
            b.al    DistLoop

CopyToW0:
            extr    w0, w1, w0, #1
            lsr     w1, w1, #1
            cbz     w2, Done
DistLoop:
          ❶ sub     w2, w2, #1
            tst     w3, #1
            lsr     w3, w3, #1
            bne     CopyToW0

          ❷ ror     w0, w0, #1
            cbnz    w2, DistLoop
Done:

该循环的主要入口点是 DistLoop。它通过递减 W2 中保存的循环计数器 ❶ 开始。稍后,这段代码将检查 W2 中的值,以查看循环是否完成。接下来,tst 指令检查位图的位 0 是否为 1。如果是,代码需要将 W1 中 LO 位的一个位复制到 W0 中;否则,保留当前的位值。

bne 指令如果需要从 W1 中复制一个位,则控制流转移到 CopyToW0;否则,如果要保留 W0 中的当前位,则会跳转到 ❷。ror 指令将现有的 W0 LO 位旋转到 HO 位位置(经过 32 次迭代后,该位会回到原位置)。在 ror 操作后,代码检查循环是否执行了 32 次(通过 cbnz 指令)。如果是,代码退出;否则,继续重复。

如果 W3 的 LO 位是 1,则控制流转移到 CopyToW0 标签,该标签负责将 W1 的(当前)LO 位移入 W0。CopyToW0 中的代码使用 extr 指令从 W1 中获取位 0,并将其放入 W0 的位 31(将 W0 中的位 1 到 31 向下移 1 位)。lsr w1, w1, #1 指令将已使用的位从 W1 中移除,并将下一个要合并到 W1 的位放入位 0 中。经过快速检查循环是否完成后,代码会跳转到 DistLoop 并重复。

注:

如果 ARM 有一条指令,可以通过进位标志将寄存器向右旋转一个位,这段代码会更简单。 然而,由于没有这样的指令,这段代码必须通过使用 extr来模拟它。

合并位的通用算法比一般的分配稍微高效一些。下面的代码将通过 W3 中的位掩码从 W1 中提取位,并将结果保存在 W0 中:

// W0- Destination register
// W1- Source register
// W3- Bitmap with 1s representing bits to copy to W0

        mov    w0, wzr    // Clear destination register.
        b.al   ShiftLoop

ShiftInW0:
        extr    w0, w0, w1, #31
      ❶ lsl     w1, w1, #1

ShiftLoop:
      ❷ tst     w3, #0x80000000
        lsl     w3, w3, #1
        bne     ShiftInW0   // W3 HO bit was set.

      ❸ lsl     w1, w1, #1
        cbnz    w3, ShiftLoop

与分配代码类似,合并代码通过位图 ❷ 中的 1 位,逐位地从 W1 复制到 W0。extr 指令将 W1 的第 31 位和 W0 的第 0 到第 30 位创建为一个 32 位字符串,然后将结果放入 W0。在每次循环迭代中,代码将 W1 中的位向左移动一位 ❶ ❸,以便下一个可能移入 W0 的位位于最高位位置。与分配代码不同,这段代码将在处理完位图中所有 1 位后终止。

另一种合并位的方式是通过查表。通过一次抓取一个字节的数据(这样你的表不会变得太大),你可以将该字节的值用作查找表的索引,将所有位合并到位 0。最后,你可以将每个字节低端的位合并在一起。在某些情况下,这可能会产生更高效的合并算法。实现留给你来完成。

12.5.2 创建打包的位串数组

尽管效率低得多,但还是可以创建大小不是 8 位倍数的元素数组。缺点是计算数组元素的“地址”并操作它需要进行大量额外的工作。本节展示了一些打包和解包任意长度位数的数组元素的示例。

为什么要使用位对象数组呢?答案很简单:节省空间。如果一个对象仅消耗 3 位,你可以通过打包数据,而不是为每个对象分配一个完整字节,来将同样的空间里放入 2.67 倍数量的元素。对于非常大的数组,这可以带来可观的节省。当然,节省空间的代价是速度:你必须执行额外的指令来打包和解包数据,从而减慢了访问速度。

定位数组元素在大块位中的偏移量的计算几乎与标准数组访问完全相同:

`Element_Address_in_bits` =
    `Base_address_in_bits` + `index` × `element_size_in_bits`

一旦你计算出元素的位地址,你必须将其转换为字节地址(因为在访问内存时必须使用字节地址),然后提取指定的元素。由于数组的基地址几乎总是从字节边界开始,你可以使用以下公式来简化这个任务:

`Byte_of_1st_bit` =
    `Base_Address` + (`index` × `element_size_in_bits`) / 8

`Offset_to_1st_bit` =
    (`index` × `element_size_in_bits`) % 8

例如,假设你有一个由 200 个 3 位对象组成的数组,声明如下:

AO3Bobjects:
    .space  (200 * 3)/8 + 2  // "+2" handles truncation.

前面维度中的常量表达式为 600 位(200 个元素,每个 3 位长)保留了足够的字节空间。如注释所示,表达式在末尾添加了 2 个额外字节,以确保不会丢失任何奇数位,并允许在向数组存储数据时访问数组末尾之后的 1 个字节。(在此示例中不会丢失奇数位,因为 600 可以被 8 整除,但一般来说,不能依赖这一点;添加 2 个额外字节通常不会有问题。)

现在假设你想访问这个数组的i位置上的 3 位元素。你可以使用以下代码提取这些位:

// Extract the `i`th group of 3 bits in AO3Bobjects
// and leave this value in W0:

        mov     w2, wzr         // Put i / 8 remainder here.
        ldr     w0, [fp, #i]    // Get the index into the array.

        mov     w4, #3
        mul     w0, w0, w4      // W0 = W0 * 3 (3 bits/element)
        ubfiz   w2, w0, #0, #3  // W2 = LO 3 bits of W0
        lsr     w0, w0, #3      // W0 / 8 -> W0 and W0 % 8 -> W2

// Okay, fetch the word containing the 3 bits you want to
// extract. You have to fetch a word because the last bit or two
// could wind up crossing the byte boundary (that is, bit
// offset 6 and 7 in the byte).

        lea     x1, AO3Bobjects

        ldrh    w0, [x1, x0]    // Fetch 16 bits.
        lsr     w0, w0, w2      // Move bits down to bit 0.
        And     w0, w0, #0b111  // Remove the other bits.

向数组中插入元素稍微复杂一些。除了计算数组元素的基地址和位偏移外,还必须创建一个掩码来清除目标位置的位,即你要插入新数据的位置。以下代码将 W0 的低 3 位插入到 AO3Bobjects 数组的i元素中:

Masks:
            .hword    ~ 0b0111,            ~ 0b00111000
            .hword    ~ 0b000111000000,    ~ 0b1110
            .hword    ~ 0b01110000,        ~ 0b001110000000
            .hword    ~ 0b00011100,        ~ 0b11100000
              .
              .
              .

// Get the index into the array (assume i is a local variable):

            ldr     w1, [fp, #i]

// Use LO 3 bits as index into Masks table:

 and     w2, w1, #0b111
            lea     x4, Masks
            ldrh    w4, [x4, w2, uxtw #1] // Get bitmask.

// Convert index into the array into a bit index.
// To do this, multiply the index by 3:

            mov     w3, #3
            mul     w1, w1, w3

// Divide by 8 to get the byte index into W1
// and the bit index (the remainder) into W2:

            and     w2, w1, #0b111
            lsr     w1, w1, #3

// Grab the bits and clear those you're inserting:

            lea     x5, AO3Bobjects
            ldrh    w6, [x5, w1, uxtw #0]
            and     w3, w4, w6

// Put your 3 bits in their proper location:

            lsl     w0, w0, w2

// Merge bits into destination:

            orr     w3, w3, w0

// Store back into memory:

            strh    w3, [x5, w1, uxtw #0]

假设 AO3Bobjects 全为 0,i 为 5,并且在执行这段代码时 W0(待插入的值)为 7,那么执行完这段代码后,前几个字节将包含 0x38000。因为每个元素是 3 位,数组看起来是:

000 000 000 000 000 111 000 000 000 000 00 ...

其中位 0 是最左边的位。将 32 位翻转,使其更易读,并将其分成 4 位一组,以便于转换为十六进制,得到:

0000 0000 0000 0011 1000 0000 0000 0000

其值为 0x38000。

这段代码使用查找表(Masks)来生成清除数组中适当位置所需的掩码。该数组的每个元素都包含所有 1,除了在需要清除的给定位偏移位置处有三个 0。注意使用了取反运算符(~)来反转表中的常量。

12.5.3 搜索位

一个常见的位操作是定位位段的结束。该操作的一个特殊情况是定位 16 位、32 位或 64 位值中第一个(或最后一个)被置位或清除的位。本节探讨了处理这种特殊情况的方法。

第一个设置的位指的是在一个值中,从位 0 扫描到高位,找到第一个包含 1 的位。第一个清除的位有类似的定义。最后一个设置的位指的是在一个值中,从高位扫描到位 0,找到第一个包含 1 的位。同样,最后一个清除的位也有类似的定义。

扫描第一个或最后一个位的一个显而易见的方法是使用循环中的移位指令,并计算在移出 1(或 0)之前的迭代次数。迭代次数指定了位置。以下是一些示例代码,检查 W0 中第一个设置的位(从位 0 开始)并返回该位位置到 W1 中:

 mov  w1, #31    // Count off the bit positions in W1.
TstLp:    adds w0, w0, w0 // Check whether the current bit
                          // position contains a 1.
          bcs  Done       // Exit loop if it does.
          subs w1, w1, #1 // Decrement your bit position counter by 1.
          bpl  TstLp      // Exit after 32 iterations.
Done:

请注意,如果 W0 没有设置位,这段代码将在 W1 中返回-1。

寻找第一个(或最后一个)设置的比特是一个非常常见的操作,因此 Arm 专门添加了一条指令来加速这个过程:clz(计数前导 0 比特)。特别地,clz 指令计数前导 0 的数量,这可以告诉你最重要的设置比特的位置。考虑以下代码:

clz w0, w0
sub w0, w0, #31
neg w0, w0

这段代码计算 W0 中最高位置上的 1 的比特位置(并将结果保留在 W0 中)。如果 W0 包含 0(没有前导的设置位),则返回-1。

别忘了,cls 并不是计数前导的设置比特,而是前导的符号比特。为了计数包含 1 的前导(HO)比特,反转该数值并使用 clz 来计数前导 0 比特。为了计数尾随的 0 或 1 比特(即从最低位位置开始的 0 或 1 比特序列),使用 rbit 指令来反转比特,然后计数你感兴趣的 HO 比特。

12.5.4 合并位字符串

另一个常见的位字符串操作是通过合并或交错来自两个源的比特,生成一个单一的位字符串。例如,以下代码序列通过交替合并两个 16 位字符串的比特,创建一个 32 位字符串:

 mov  w2, #16
          lsl  w0, w0, #16     // Put LO 16 bits in the HO
          lsl  w1, w1, #16     // bit positions.
MergeLp:  extr w3, w3, w0, #31 // Shift a bit from W0 into W3.
          Extr w3, w3, w1, #31 // Shift a bit from W1 into W3.
          lsl  w0, w0, #1      // Move on to the next bit in
          lsl  w1, w1, #1      // W0 and W1.
          subs w2, w2, #1      // Repeat 16 times.
          bne  MergeLp

这个具体的例子将两个 16 位值合并在一起,交替排列它们的比特,生成结果值。为了更快地实现此代码,可以展开循环,去掉三分之一的指令。

通过一些小的修改,你可以将四个 8 位值合并在一起,或者合并来自源字符串的其他比特集合。例如,以下代码将 W0 中的比特 0 到 5、W1 中的比特 0 到 4、W0 中的比特 6 到 11、W1 中的比特 5 到 15,最后 W0 中的比特 12 到 15 进行复制:

bfi  w3, w0, #0, #6   // W0[0:5] to W3[0:5]
bfi  w3, w1, #6, #5   // W1[0:4] to W3[6:10]
lsr  w0, w0, #6
bfi  w3, w0, #11, #6  // W0[6:11] to W3[11:16]
lsr  w1, w1, #5
bfi  w3, w1, #17, #11 // W1[5:15] to W3[17:27]
lsr  w0, w0, #6
bfi  w3, w0, #28, #4  // W0[12:15] to W3[28:31]

这段代码将结果存储在 W3 中,从 W0 和 W1 中提取比特。

12.5.5 从位字符串中分散比特

你还可以从位字符串中提取并分配比特到多个目标,这被称为分散比特。以下代码从 W0 中取出 32 位值,并将交替的比特分配到 W1 和 W3 寄存器的低 16 位中:

 ldr     w0, =0x55555555
            mov     w3, wzr
            mov     w1, wzr
            mov     w2, #16     // Count the loop iterations.
ExtractLp:  adds    w0, w0, w0  // Extract odd bits to W3.
            adc     w3, w3, w3
            adds    w0, w0, w0  // Extract even bits to W1.
            adc     w1, w1, w1
 subs    w2, w2, #1  // Repeat 16 times.
            bne     ExtractLp

这段代码在 W1 中生成 0xffff,在 W0 中生成 0x0000。

12.5.6 搜索比特模式

另一个你可能需要的与比特相关的操作是能够在一串比特中搜索特定的比特模式。例如,你可能想要找到从位字符串中的特定位置开始,第一个出现 0b1011 的比特索引。本节将探讨一些简单的算法来完成这个任务。

要搜索特定的比特模式,你必须知道四个细节:

  • 模式

  • 该模式的长度

  • 要搜索的位字符串,称为

  • 你正在搜索的位字符串的长度

搜索的基本思路是根据模式的长度创建一个掩码,并用该值掩码源代码的副本。然后你可以直接将模式与掩码后的源代码进行比较,如果相等,则完成;如果不相等,则增加一个位位置计数器,将源代码右移一位,再试一次。你将重复操作[length(source) – length(pattern)]次。如果在那次数之后未能找到匹配的位模式,算法失败,因为它已经耗尽了源操作数中可以匹配模式长度的所有位。

第 12-2 节搜索一个 4 位的模式。

// Listing12-2.S
//
// Demonstration of bit string searching

        #include    "aoaa.inc"

        .text
        .pool
ttlStr: wastr   "Listing 12-2"
noMatchStr:
        wastr   "Did not find bit string\n"

matchStr:
        wastr   "Found bit string at posn %d\n"

        proc    getTitle, public
        lea     x0, ttlStr
        ret
        endp    getTitle

        proc    asmMain, public

 locals  am
        word    pattern
        word    source
        word    mask
        byte    am.stk, 64
        endl    am

        enter   am.size

        // Initialize the local variables this code
        // will use:

        mov     w0, #0b1011110101101100
        str     w0, [fp, #source]
        mov     w0, #0b1011
        str     w0, [fp, #pattern]
        mov     w0, #0b1111
        str     w0, [fp, #mask]

        // Here's the code that will search for the
        // pattern in the source bit string:

        mov     w2, #28             // 28 attempts because 32 - 4 = 28
                                    // (len(src) - len(pat))
        ldr     w3, [fp, #mask]     // Mask for the comparison.
        ldr     w0, [fp, #pattern]  // Pattern to search for
        and     w0, w0, w3          // Mask unnecessary bits in W0.
        ldr     w1, [fp, #source]   // Get the source value.
ScanLp: mov     w4, w1              // Copy the LO 4 bits of W1.
        and     w4, w4, w3          // Mask unwanted bits.
        cmp     w0, w4              // See if you match the pattern.
        beq     Matched
        sub     w2, w2, #1          // Repeat specified number of times.
        lsr     w1, w1, #1
        cbnz    w1, ScanLp

// Do whatever needs to be done if you failed to
// match the bit string:

        lea     x0, noMatchStr
        bl      printf
        b.al    Done

// If you get to this point, you matched the bit string.
// You can compute the position in the original source as 28 - W2.

Matched:
        mov     x1, #28
        sub     x1, x1, x2
        mstr    x1, [sp]
        lea     x0, matchStr
        bl      printf
Done:
        leave                       // Return to caller.
        endp    asmMain

下面是第 12-2 节的构建命令和示例输出:

% ./build Listing12-2
% ./Listing12-2
Calling Listing12-2:
Found bit string at posn 2
Listing12-2 terminated

如你所见,程序正确地在源代码中定位了位模式。

12.6 继续前进

汇编语言以其强大的位操作能力而闻名,这种能力在高级语言中很难复制。本章描述了 64 位 ARM CPU 的这些能力。它从描述位操作的有用定义开始,然后介绍了一系列操作位数据的指令。本章还讨论了如何使用 PSR 中的条件码标志作为位数据,并使用相应的指令来操作这些标志,具体包括负(N)、零(Z)、进位(C)和溢出(V)标志。

在讨论完基本的位操作指令集后,本章讲解了这些指令的应用,包括位串的打包和解包,将一个位串插入另一个位串,从源串中提取位串,合并和分配位,处理打包的位数组,搜索位串,合并位串,以及将位从位串中分散出去。

大部分情况下,本章总结了对新 ARM 汇编语言指令的讨论。后续章节讨论了这些指令的应用以及各种软件工程主题。例如,下一章专注于你可以用来简化汇编语言程序的宏。

12.7 更多信息

  • 关于位操作的终极书籍是由 Henry S. Warren Jr.所著的《Hacker’s Delight》,第二版(Addison-Wesley Professional,2012)。虽然这本书使用 C 语言作为示例,但它讨论的几乎所有概念也适用于汇编语言程序。

第十三章:13 宏和 Gas 编译时语言

本章讨论了 Gas 编译时语言(CTL),包括其宏扩展功能。,是 CTL 中等同于过程的概念,是一个标识符,汇编器将其扩展为额外的文本。这允许你通过一个标识符来缩写大量代码。Gas 的宏功能相当于计算机语言中的计算机语言;也就是说,你可以在 Gas 源文件中编写短程序,其目的是生成其他将由 Gas 汇编的源代码。

Gas CTL 包含宏、条件语句(如 if 语句)、循环以及其他语句。本章介绍了许多 Gas CTL 特性,以及如何使用它们来减少编写汇编语言代码的工作量。

13.1 Gas 编译时语言解释器

Gas 实际上是将两种语言合并到一个程序中。运行时语言是你在前几章中阅读的标准 ARM/Gas 汇编语言。之所以称之为运行时语言,是因为你编写的程序在你运行可执行文件时执行。Gas 包含一个解释器,用于解释第二种语言——Gas CTL

Gas 源文件包含 Gas CTL 和运行时程序的指令,Gas 在汇编(编译)过程中执行 CTL 程序。一旦 Gas 完成汇编,CTL 程序便终止。图 13-1 显示了 Gas 汇编器和你的汇编语言源代码在编译时与运行时之间的关系。

图 13-1:编译时执行与运行时执行

CTL 应用程序并不是 Gas 发出的运行时可执行文件的一部分,尽管 CTL 应用程序可以为你编写部分运行时程序。事实上,这正是 CTL 的主要目的。通过自动代码生成,CTL 使你能够轻松且优雅地生成重复的代码。通过学习如何使用 Gas CTL 并正确应用它,你可以像开发高级语言(HLL)应用程序一样快速开发汇编语言应用程序(甚至更快,因为 Gas 的 CTL 让你能够创建非常接近 HLL 的结构,简称 VHLL)。

13.2 C/C++ 预处理器

Gas CTL 由两个独立的语言处理器组成:Gas 内置宏处理器和 C/C++ 预处理器(CPP)。正如在第一章中所提到的,标准的 Gas 汇编语言源文件使用 .s 后缀。然而,如果你指定 .S 作为后缀,Gas 会在处理文件之前先通过 CPP 运行该源文件(参见图 13-2)。CPP 会生成一个临时源文件(使用 .s 后缀),然后 Gas 汇编器将其汇编成目标代码。

图 13-2:Gas 的 C 预处理器处理过程

非常重要的一点是要记住,CPP 独立于 Gas 运行,并且在 Gas 汇编汇编语言源文件之前运行。特别是,Gas 的宏处理发生在 CPP 运行之后。因此,你不能使用 Gas 语句、符号或 Gas 宏来影响 CPP 的操作。稍后在本章中,我将指出在混合两种语言的宏功能时需要注意的地方。本节描述了 CPP 的各种特性。

13.2.1 #warning 和 #error 指令

在使用 CPP 编写宏时,你有时会遇到一个问题(例如一个不正确的参数),你希望在汇编过程中将其报告为错误或诊断消息。为此,你可以使用 #warning 和 #error 诊断语句,语法如下:

#error `arbitrary text`
#warning `arbitrary text`

这些语句必须单独出现在源代码行上;在 # 字符前面,除了空白字符(空格和制表符)外,不应出现其他内容。(从技术上讲,空白字符可以出现在 # 和错误或警告标记之间,但良好的编程风格建议将它们保持在一起。)

在汇编过程中(或者更准确地说,在 CPP 处理源文件时),系统应该显示诊断消息,并打印包含 #error 或 #warning 语句的行,包括所有的 arbitrary_text,直到行的末尾。按照惯例,大多数程序员会将错误或警告消息(arbitrary_text)括在引号中,但这并不是绝对必要的。

如果 CPP 遇到任何 #error 语句,它将在 CPP 完成扫描源文件后终止汇编过程,而不会运行 Gas 汇编器来汇编该文件。在这种情况下,你需要在 Gas 处理文件之前修改源文件,以消除错误消息(例如,报告汇编语言源代码中的任何错误)。

如果 CPP 遇到任何 #warning 语句,它会在汇编过程中打印相应的消息,但会在 CPP 完成预处理源文件后继续汇编。因此,你可以使用 #warning 语句在汇编和预处理过程中显示 arbitrary_text。

13.2.2 使用 CPP 定义编译时常量

你可以使用 CPP 的 #define 语句在源文件中创建常量定义:

#define `identifier` `arbitrary_text`

当 CPP 处理源文件时,它会将随后的标识符替换为 arbitrary_text。程序员通常使用此语句,例如在源文件中定义显式(命名)常量,如下例所示:

#define pi 3.14159

在汇编语言程序中,你通常会使用 .equ、.set 或 = 指令来定义命名常量,如下例所示:

maxCnt = 10

然而,Gas 中的各种错误可能会导致你无法像预期那样使用这些常量。考虑以下情况:

pi = 3.14159
    .
    .
    .
   .double pi

如果你尝试汇编这个,Gas 会抱怨 3.14159 不是一个有效的常量,pi 也不是一个有效的浮点常量。(在 macOS 下的 Clang 汇编器会接受 pi = 3.14159,但仍然会抱怨 pi 不是一个有效的浮点常量。)但是,如果你将其替换为

#define pi 3.14159
   .
   .
   .
   .double pi

然后 Gas 会正常汇编代码,因为 CPP 会预处理源文件并将每个出现的 pi 替换为 3.14159。因此,当 Gas 实际看到源文件时,它会发现

 .double 3.14159

这是完全可以接受的。这是一个很好的例子,说明为什么在 Gas 源文件中使用 CPP 会有帮助:它为你提供了一些能力,比如真实常量定义,这些是仅用 Gas 无法实现的。

因为 CPP 会在每次找到该标识符(在字符串或字符常量外部)时进行文本替换,所以你不仅限于使用 #define 来定义数字常量。你可以在 #define 后面定义字符常量、字符串常量,甚至任意文本(包括什么都不定义):

#define hw "Hello, World!"

如果你更倾向于使用 xor 助记符而不是 eor 助记符,你甚至可以像下面这样做:

#define xor eor
   .
   .
   .
  xor x1, x0, x2

尽管像这样重新定义指令助记符通常被认为是糟糕的编程实践,但 ARM 在其“指令别名”中到处都这么做。如果 ARM 这么做没问题,那么如果它能让你的代码对你来说更易读,也没有理由你不能这么做。

define 语句的另一个重要用途是创建 CPP 可以识别的符号。CPP 对源文件中出现的所有标识符毫无察觉,除了你用 #define 语句创建的那些标识符。正如你将在下一节中看到的那样,你有时会希望在 CPP CTL 语句中使用涉及命名常量的各种表达式。这些命名常量必须通过 #define 语句来定义,而不是 Gas 的 equate 指令之一。

13.2.3 CPP 编译时表达式

某些 CPP 语句允许涉及常量的简单算术表达式。算术运算符是常见的 C 算术运算符,包括以下这些:

+  -  *  /  %  == != <  <=  >  >=  !  ~  && ||  &  | << >>

注意,CPP 仅支持(有符号的)64 位整数和字符表达式,如果你尝试使用浮点或字符串常量,它会报告错误。只要你之前用 #define 语句声明了该名称,就可以在 CPP CTL 表达式中使用命名常量。

你可以在 CPP CTL 表达式中使用以下 CPP 内建函数:

defined(`identifier`)

这个函数如果标识符之前在 #define 语句中定义过,会返回 1;如果没有此类定义,则返回 0(注意,你也可以使用 GCC 的 -D identifier=value 命令行选项来定义符号)。defined() 函数只识别在 #define 语句中定义的符号,这是前一节中提到的“重要用途”的一个好例子。如果你在这里传递一个普通的 Gas 汇编语言标识符,即使该定义在源文件的早期出现,函数也会返回 0。

13.2.4 条件汇编

CPP 提供了几个语句,允许你在处理源文件时做出决策。以下是这些指令:

#if `expression`
 .
 .
 .
#elif `expression`  // This is optional and may appear multiple times.
 .
 .
 .
#else             // This is optional.
 .
 .
 .
#endif

#ifdef `identifier`
 .
 .
 .
#else             // This is optional.
 .
 .
 .
#endif

#ifndef `identifier`
 .
  .
 .
#else             // This is optional.
 .
 .
 .
#endif

在预处理期间,CPP 将评估表达式。如果它的值为非零(即 true),则 #if 或 #elif(else if)语句将处理文本,直到下一个 #elif、#else 或 #endif 语句。

如果表达式的结果为 false,CPP 将跳过接下来的文本(直到下一个 #elif、#else 或 #endif 语句),并且不会将该文本写入临时输出文件。因此,Gas 在汇编阶段不会汇编该文本。

请记住,这种条件处理发生在预处理阶段(汇编),而不是在运行时。这不是你在高级语言(HLL)中找到的通用 if/then/elseif/else/endif 语句。条件编译语句控制是否将指令实际出现在最终的目标代码中,这对于那些使用过 C/C++ 等高级语言进行条件编译的人来说应该很熟悉。

ifdef 语句等效于以下内容:

#if defined(`identifier`)
 .
 .
 .
#endif

CPP 检查标识符是否之前已经通过 #define 语句(或 -D 命令行选项)定义。如果是,CPP 将处理 #if(或 #ifdef)后面的文本,直到 #endif(或直到遇到 #elif 或 #else 语句,如果存在的话)。

ifndef(如果未定义)语句等效于此:

#if !defined(`identifier`)
 .
 .
 .
#endif

ifdef 和 #ifndef 语句在为不同执行环境编写的代码中很常见。考虑以下示例:

#ifdef isMacOS

    `Code written for macOS`

#else

    `Assume the code was written for Linux or Pi OS.`

#endif

在此之前,如果出现以下语句,则前面的代码将为 macOS 编译该部分:

#define isMacOS

如果没有出现此定义,代码将编译为 Linux 或 Pi OS 代码。

另一个常见的条件汇编用途是将调试和测试代码引入你的程序中。作为一种典型的调试技术,许多 Gas 程序员会在关键位置插入打印语句,使他们能够追踪代码并在各个检查点显示重要的值。然而,这种技术的一个大问题是,他们必须在项目完成之前移除调试代码。此外,程序员经常忘记移除一些调试语句,这会导致最终程序中的缺陷。最后,在移除调试语句后,这些程序员往往会发现他们需要这些语句来调试其他问题。因此,他们必须一遍又一遍地插入和移除相同的语句。

条件汇编提供了该问题的解决方案。通过定义一个符号(比如 debug)来控制程序中的调试输出,你可以通过修改一行源代码来启用或禁用所有调试输出,正如下面的代码片段所示:

// Uncomment to activate debug output or -D debug on the command line.

// #define debug
      .
      .
      .
     #ifdef debug

        #warning *** DEBUG build

        mov  x1, [fp, #i]
        mstr x1, [sp]
        lea  x0, debugMsg
        bl   printf

     #else

        #warning *** RELEASE build

     #endif

只要你将所有调试输出语句用 #if 语句包围(如前面的代码所示),就不必担心调试输出会意外出现在最终应用程序中。注释掉调试符号定义将自动禁用所有此类输出(或者,更好的是,使用 -D debug 命令行选项在需要时打开输出)。同样,即使调试语句已经完成它们的即时目的,你也可以将它们保留在代码中,因为条件汇编使它们容易停用。以后,如果你决定在汇编期间查看相同的调试信息,你可以通过定义调试符号重新启用它。

13.2.5 CPP 宏

之前,本章使用 #define 语句定义了编译时常量,这是宏定义的一个特例。本节将更深入地描述 CPP 宏定义和展开,包括宏参数、可变参数列表和其他 CPP 宏定义功能的讨论。通过使用这些信息,你将能够在 Gas 汇编语言源文件中创建并使用 CPP 宏。

13.2.5.1 函数宏

宏是 CPP 用来将标识符替换为任意文本的机制。当使用 #define 定义常量时,你是在告诉 CPP 将后续每次出现该标识符的地方替换为相应的文本,也就是常量。

然而,CPP 提供了第二种类型的宏,即函数宏,它更像是一个(编译时)函数,支持任意数量的参数。以下示例演示了一个单参数宏:

#define lcl(arg1)  [fp, #arg1]
   .
   .
   .
  ldr w0, lcl(varName)

最后一条语句展开为

 ldr w0, [fp, #varName]

因为宏 lcl 展开为 [fp, #varName]。CPP 称这些为函数宏,因为它们的调用方式类似于 C 编程语言中的函数调用。

13.2.5.2 CPP 宏参数

函数宏支持任意数量的参数。你可以通过以下方式指定零个参数

#define zeroArgs()  `text`

其中 zeroArgs() 将展开为指定的文本。

这是以下两种宏声明之间的区别,在调用它们时会表现出来:

#define noArgs  `text1`
#define zeroArgs() `text2`

你用 noArgs 调用第一个宏,用 zeroArgs() 调用第二个宏。如果宏声明有一个空的圆括号,宏调用也必须包含空的圆括号。你可以使用这种声明方式来区分常量声明和宏声明。

你也可以在 #define 语句中指定两个或更多参数:

#define twoArgs(arg1, arg2)  `text to expand`

以下是一个 twoArgs() 调用的示例:

mov w0, twoArgs(1, 2)

在调用 twoArgs() 时,必须提供恰好两个参数,否则 Gas 会报告错误。通常,宏调用中提供的参数数量必须与 #define 声明中的参数列表完全匹配。

当 CPP 处理宏调用时,通常通过扫描逗号来分隔实际参数。它会忽略出现在字符串或字符常量中的逗号,或出现在由圆括号或方括号括起来的表达式中的逗号:

 singleArg("Strings can contain commas, that's okay!")
  singleArg(',')    // Also okay
  singleArg((1,2))  // (1,2) is a single argument.

从 CPP 的角度来看,这些宏调用中的每一个都只有一个参数。

13.2.5.3 宏参数展开问题

正如任何有经验的 C 程序员所知道的,你在指定宏参数时必须小心,以避免在展开过程中产生意外结果,尤其是当它们涉及算术表达式时。考虑以下宏:

#define reserve(amt)  amt + 1

现在考虑以下宏调用:

.space  reserve(Size) * 2  // Size is a constant.

这里的预期是保留的空间是 reserve()宏通常指定的两倍。然而,考虑一下这个宏的实际展开:

.space  Size + 1 * 2

Gas 的算术规则指定乘法的优先级高于加法,因此这将展开为 Size + 2,而不是(Size + 1) × 2,这是期望的展开。C 程序员通过总是用括号将宏展开(展开为算术表达式)括起来,并且他们总是将宏参数本身用括号括起来,从而绕过这个问题,如下例所示:

#define reserve(amt)  ((amt) + 1)

这通常解决了算术表达式中间宏展开的问题。

出于同样的原因,通常建议将你传递作为宏参数的任何表达式用括号括起来:

.space  reserve((Size + 5)) * 2

因为展开可能会根据运算符优先级产生意外的结果(例如,假设 reserve 定义为 amt * 2,如果你没有用括号将实际参数表达式括起来,它将展开为 Size + 5 * 2)。

13.2.5.4 可变参数列表

CPP 提供了一种指定可变数量参数的机制:

#define varArgs(...)  `text to expand`

要引用参数,请使用预定义的 VA_ARGS 符号(它以两个下划线开始和结束)。CPP 会将整个参数集替换为 VA_ARGS这包括可变参数列表中所有出现的逗号。考虑以下宏定义和调用:

#define bytes(...) __VA_ARGS__
   .
   .
   .
  .byte bytes(1, 2, 3, 4)

.byte 语句展开为以下内容:

.byte 1, 2, 3, 4

可变参数列表允许零个实际参数,因此调用 bytes()是完全合法的(并且根据之前的定义,它会展开为空字符串)。因此

.byte bytes()

将展开为

.byte

有趣的是,这不会产生错误(Gas 不会为此语句生成任何代码)。

尽管整个参数列表的展开有时很有用,但你更常见的需求是从可变参数列表中提取单个参数,正如接下来的两节所讨论的那样。

13.2.5.5 宏组合和递归宏

CPP 不支持递归宏调用。如果宏的名称出现在展开文本中,CPP 将简单地将该名称作为文本输出,以便由 Gas 汇编。这是遗憾的,因为递归在处理迭代时非常有用,而 CPP 并不提供任何循环结构。在第 13.2.5.9 节“使用宏进行迭代”中,我将在第 757 页提供一个解决方法;与此同时,我将讨论 CPP 如何处理宏展开,当一个宏调用另一个宏时。

考虑以下宏定义和调用:

#define inc(x) ((x)+1)
#define mult(y) ((y)*2)
   .
   .
   .
  .byte mult(inc(5))

当 CPP 遇到另一个宏调用的参数列表中的宏调用时,它将在将该文本传递给外层宏调用之前,先展开该参数。.byte语句的展开发生在两个步骤中:

.byte mult(((5) + 1))  // First step

然后

.byte (((5) + 1) * 2)   // Second step

这显然等于:

.byte 12

现在考虑以下示例:

#define calledMacro(x) mov w0, x
#define callingMacro(y) calledMacro(y)
   .
   .
   .
  callingMacro(5)

当 CPP 遇到此示例末尾的宏调用时,它会将 callingMacro(5)展开为以下文本:

 calledMacro(5)

然后 CPP 将展开此宏为以下内容:

 mov w0, 5

只要 CPP 继续在展开文本中找到宏调用(除非是递归调用),它将继续展开这些调用,无论该过程需要多少次迭代。

注意

#define calledMacro(x) mov w0, x 宏应该真正是 #define calledMacro(x) mov w0, #x ,使用本书中迄今为止介绍的语法。然而,#* 是一个 CPP 操作符(字符串化,稍后会描述),它将实际参数5转换为字符串“5”。幸运的是,Gas 在这个例子中接受一个普通常量来代替#constant,用于立即寻址模式。*

13.2.5.6 宏定义的限制

CPP 宏的语法如下:

#define identifier(`parameters)` `text to expand` \n

其中(参数)是可选的,\n 表示换行符。CPP 不允许在展开的文本中包含任何换行符。因此,宏只能展开为一行文本。

CPP 确实允许如下所示的宏定义:

#define multipleLines(parms) `text1` \
                             `text2` \
                              .
                              .
                              .
                             `textn`

这将把宏定义分布到源文件中的n行中。除了最后一行外,每一行必须以反斜杠字符(\)结束,并紧接着一个换行符。

尽管这个宏在物理上跨越了多行源代码,但它仍然是单行文本,因为 CPP 会删除所有反斜杠后面的换行符。因此,你不能创建如下的宏:

#define printi(i)        \
        lea   x0, iFmtStr  \
        lea   x1, i        \
        ldr   x1, [x1]     \
        mstr  x1, [sp]     \
 bl    printf
         .
         .
         .
        printi(var)

这个宏定义无法工作,因为 CPP 将把所有汇编语言语句展开成一行(它们之间用空格隔开),从而产生语法错误。遗憾的是,Gas 似乎没有提供一种通用机制来在同一行上提供多个汇编语句。因此,你不能使用展开为多个汇编语言语句的 CPP 宏。(幸运的是,Gas 宏允许这样做,你将在第 13.3.4 节“Gas 宏”中了解,位于第 765 页)。

注意

在某些 CPU 上,Gas 允许使用分号 (;) 字符将多个语句放在同一行。然而,ARM 将分号视为行注释字符;一个分号等价于两个正斜杠 (//)。在 Gas 中的表现可能会有所不同;例如,在 Pi OS 下,你可以使用分号作为语句分隔符。

CPP 的单行宏定义有另一个严重的缺陷:你不能将 CPP 的条件编译语句(例如 #if)嵌入到宏中,因为条件编译语句必须出现在源代码行的开头。这很不幸,因为在宏中做决策的能力是非常有用的。幸运的是,仍然有几个解决方法。

首先,你可以将宏定义放在条件编译序列中,如下例所示:

#ifdef isMacOS
#define someMacro `text to expand if macOS`
#else
#define someMacro `text to expand if not macOS`
#endif

这个序列有两个宏,其中只有一个宏会在给定的汇编中被定义。因此,你可以将(推测是)仅限 macOS 的代码放入第一个宏定义中,将 Linux 代码放入第二个宏定义中。

使用两个单独的宏定义在某些情况下是有效的,但并不是所有情况下都行得通;有时你确实需要在宏展开中插入条件文本。我将在第 13.2.5.8 节“条件宏”中提供第二个解决方法,以解决这个问题,详见第 756 页。

13.2.5.7 文本连接和字符串化操作符

CPP 提供了两个用于操作文本数据的特殊操作符:连接操作符和字符串化操作符。本节将介绍这两个操作符。

标记是 C/C++ 语言识别的实体,例如标识符或操作符。CPP 连接操作符 (##) 将两个标记组合成一个宏,形成一个单一的标记。例如,在宏体内,以下文本会生成单一的标识符标记:

ident ## ifier

标记和 ## 操作符之间可以有任意数量的空白字符。CPP 会删除所有空白字符,并将两个标记连接在一起——只要结果是一个合法的 C/C++ 标记。如果宏参数标识符出现在 ## 操作符的任一侧,CPP 会在进行连接之前展开该参数为实际参数的文本。可惜的是,如果你将另一个宏作为参数传递,CPP 不会正确地展开该参数:

#define one 1
#define _e  e
#define produceOne(x) on ## x
   .
   .
   .
  produceOne(_e)  // Expands to on_e

作为一个巧妙的解决方法,你可以创建一个连接宏来为后续展开创建标识符:

#define produceOne  1
#define concat(x, y) x ## y

    mov w0, #concat(produce, One)

这将生成语句

mov w0, #produceOne

然后会展开为:

mov w0, #1

第 13.8 节“更多信息”在第 792 页包含了描述 CPP 文本连接操作符的更多网站链接。

注意

连接操作符仅在 CPP 宏中合法。CPP 会忽略 ## 在源文件中的其他地方,由 Gas 来处理它(这通常会产生错误,因为 ## 在 ARM 汇编语言中不是一个合法的标记)。

第二个 CPP 操作符是 字符串化操作符(#),你可以在宏体内按如下方式使用它

# `parmID`

其中 parmID 是宏参数之一的名称。字符串化操作符将扩展该参数并将其转换为字符串,通过在文本周围加上引号。这就是为什么之前的 #define calledMacro(x) mov w0, #x 宏无法正常工作——它将参数字符串化了,而不是将其作为整数常量处理。

13.2.5.8 条件宏

虽然你不能在宏体内包含条件编译指令(#if、#else 等),但通过一些技巧,你可以使用 CPP 创建条件宏(这也被称为 滥用 CPP)。以下是一个条件宏的示例,它实现了一个 if ... then 宏扩展,你可以根据源代码中的其他定义选择特定的扩展:

if_else(`expression`) (`true expansion`) (`false expansion`)

(真扩展)和(假扩展)是编译时表达式。if_else 宏将评估表达式;如果表达式评估为非零值,该语句将被(真扩展)替代。如果表达式评估为假,该语句将被(假扩展)替代。以下是一个简单示例:

.asciz if_else(MacOS) ("macOS") ("LinuxOS")

如果 MacOS 非零,这将产生字符串 "macOS";否则,它将产生字符串 "LinuxOS"。

if_else 宏相当复杂,我不会在这里描述它是如何工作的;它是 C 语言而非汇编语言,因此超出了本书的范围。有关此主题的资源,请参见第 13.8 节,"更多信息",见 第 792 页。以下是来自 Jonathan Heathcote 的其中一项资源中的 if_else 实现:

#define _secondArg_(a, b, ...) b

#define _is_probe_(...) _secondArg_(__VA_ARGS__, 0)
#define _probe_() ~, 1

#define _cat_(a, b) a ## b

#define _not_(x) _is_probe_(_cat_(_not_, x))
#define _not_0 _probe_()

#define _bool_(x) _not_(_not_(x))

#define if_else(condition) _if_else_(_bool_(condition))
#define _if_else_(condition) _cat_(_if_ , condition)

#define _if_1(...) __VA_ARGS__ _if_1_else
#define _if_0(...)             _if_0_else

#define _if_1_else(...)
#define _if_0_else(...) __VA_ARGS__

与条件编译指令不同,你可以将 if_else 宏嵌入到其他宏的主体中。考虑以下代码:

#define macStr(x) if_else(x) ("macOS")("Linux")

调用此宏将根据参数的编译时值产生字符串 "macOS" 或 "Linux"。

13.2.5.9 使用宏进行迭代

CPP 中的 VA_ARGS 特性对于将一组参数作为单个参数传递非常有用。然而,如果我们能够通过迭代列表中的参数逐个处理每个参数,而不是一次性处理所有参数,那就更好了。不幸的是,CPP 并不提供支持迭代的语句。由于 CPP 不支持递归,我们甚至不能(直接)使用递归来处理迭代。

然而,如果你稍微滥用 CPP,本节展示了一种有限形式的递归是可能的。本节的最终目标是创建一个宏,我们称之为 map,该宏将在变长参数列表中的每个参数上执行一个单参数宏。理想情况下,你会像这样调用 map

map(`macroName`, `arg1`, `arg2`, ..., `argn`)

然后 map 函数将生成 n 次对 macroName 的调用:

 `macroName`(`arg1`)
  `macroName`(`arg2`)
   .
   .
   .
  `macroName`(`argn)`

以下这组宏以及上一节中的 if_else 宏提供了这一功能(同样来自 Heathcote;有关实现细节,请参见第 13.8 节,"更多信息",见 第 792 页):

// Include the macro definitions for if_else from
// the previous section here.

#define _firstArg_(a, ...) a

#define _empty_()

#define eval(...) eval1024(__VA_ARGS__)
#define eval1024(...) eval512(eval512(__VA_ARGS__))
#define eval512(...) eval256(eval256(__VA_ARGS__))
#define eval256(...) eval128(eval128(__VA_ARGS__))
#define eval128(...) eval64(eval64(__VA_ARGS__))
#define eval64(...) eval32(eval32(__VA_ARGS__))
#define eval32(...) eval16(eval16(__VA_ARGS__))
#define eval16(...) eval8(eval8(__VA_ARGS__))
#define eval8(...) eval4(eval4(__VA_ARGS__))
#define eval4(...) eval2(eval2(__VA_ARGS__))
#define eval2(...) eval1(eval1(__VA_ARGS__))
#define eval1(...) __VA_ARGS__

#define _defer1(m) m _empty_()
#define _defer2(m) m _empty_ _empty_()()
#define _defer3(m) m _empty_ _empty_ _empty_()()()
#define _defer4(m) m _empty_ _empty_ _empty_ _empty_()()()()

#define _has_args(...) _bool_(_firstArg_(_end_of_args __VA_ARGS__)())
#define _end_of_args() 0

#define map(m, _firstArg_, ...)       \
  m(_firstArg_)                       \
  if_else(_has_args(__VA_ARGS__))(    \
    _defer2(_map)()(m, __VA_ARGS__) \
  )(                                    \
    /* Do nothing, just terminate */    \
  )
#define _map() map

eval 宏提供了对你作为参数传递的宏进行有限递归的功能(最多可递归 1,024 层,这允许列出最多 1,024 个条目的可变参数)。为了使 map 宏能够递归,必须将其封装在 eval 调用中。考虑以下示例:

#define inc(x) (x+1),
     .
     .
     .
    .byte eval(map(inc, 1, 2, 3, 4, 5, 6, 7)) 9

inc 宏末尾的逗号是必要的,因为调用 inc 时将会在同一行输出形式为(1 + 1),(2 + 1),...,(7 + 1)的表达式。.byte指令要求这些表达式之间用逗号分隔。

还需要注意,值 9 出现在 eval 调用之后。这是因为最后的表达式(7 + 1)后会有一个逗号,因此这个语句必须手动提供最后一个值。可以修改 map 宏使其对除了最后一个参数以外的所有参数都添加逗号,或者可以修改 inc 宏来检查特殊的哨兵值(例如负数),以便终止列表(而不输出值或逗号)。

13.2.5.10 命令行定义

如果你回顾一下第 1.10.1 节中build shell 脚本的内容,标题为“在多个操作系统下组装程序”,并参考第 36 页,你会看到 GCC 有一个命令行参数指定了正在使用的操作系统:

-D isLinux=1    (for Linux and Pi OS)
-D isMacOS=1    (for macOS)

这些命令行参数大致等同于以下两个语句:

#define isLinux 1
#define isMacOS 1

在许多情况下,你可以像在文件开头放置这些#define 语句一样,直接在源文件中引用这些定义。

如前所述,这些参数是大致等同的——而非完全等同——于#define 语句。在宏体内,这些符号可能不会像正常的定义符号那样展开。为了在宏体内使用这些符号,通常最好通过以下代码显式地创建一些#define 符号,然后在宏中引用 myIsLinux 和 myIsMacOS:

#ifdef isLinux
  #define myIsLinux 1
  #define myIsMacOS 0
#else
  #define myIsLinux 0
  #define myIsMacOS 1
#endif

如果你是通过 gcc 直接从命令行编译 Gas 源文件,可以通过使用-D 命令行选项来定义其他符号。有关详细信息,请参阅 GCC 文档。请注意,build脚本不会将-D 参数传递给 GCC,但你可以轻松修改build,如果你想定义其他符号的话。

13.2.5.11 CPP 中的#undef 语句

CPP 允许你通过使用#undef 语句忘记一个已定义的符号

#undef `identifier`

其中,identifier 是之前用#define 语句定义的符号。(如果在执行#undef 时该符号未定义,CPP 将简单地忽略该语句。)

取消符号定义可以让你重新定义它(例如,给它赋予另一个值)。如果在未先取消定义的情况下尝试重新定义符号,CPP 将报告错误。### 13.3 Gas CTL 的组成部分

Gas 的控制设施比 CPP 更接近大多数汇编语言程序员对宏扩展系统的期望。尽管 CPP 的宏功能对于某些目的很有用,但宏汇编编程通常需要一个更强大的宏系统。因此,学习 Gas 的宏功能是至关重要的。本节将介绍 Gas 控制设施的组成部分。

13.3.1 汇编期间的错误和警告

Gas 的 .err 指令类似于 CPP 的 #error。汇编过程中,Gas 将显示一条错误消息(打印包含 .err 语句的源代码行)。如果 Gas 遇到 .err 指令,它将不会生成目标文件。例如,以下代码将在运行时生成错误消息:

.err

Gas 还支持 .error 指令。其语法如下所示:

.error "`String error message`"

操作数必须是一个用引号括起来的字符串常量。在汇编过程中,如果 Gas 遇到源文件中的 .error 指令,它将显示指定的错误消息,并且不会生成目标文件。

Gas 还支持一个类似于 CPP #warning 语句的 .warning 指令,其语法如下:

.warning "`String warning message`"

再次强调,操作数必须是一个用引号括起来的字符串常量。如果 Gas 在汇编过程中遇到 .warning 指令,它将显示指定的警告消息。如果源文件中没有错误,仅有警告,Gas 将生成一个目标文件。因此,你可以将 .warning 指令作为汇编时的打印语句。

请记住,CPP 的 #warning 和 #error 语句与 Gas 的 .warning 和 .error 指令之间的区别:CPP 语句在预处理阶段执行,即在汇编过程之前,而 Gas 指令在预处理阶段之后、汇编过程中执行。如果执行了任何 #error 语句,CPP 会终止汇编过程,而不会运行汇编器(因此在这种情况下 Gas 指令不会执行)。

13.3.2 条件汇编

Gas 还支持一组类似于 CPP 的 #if/#endif 语句的条件汇编(或条件编译)指令。基本指令如下:

.if `expression1`

  `Statements to assemble if expression is nonzero`

.elseif `expression2`  // Optional, may appear multiple times

  `Statements to assemble if expression2 is nonzero`

.else  // Optional, but only one instance is legal

  `Statements to assemble if expression1, expression2, ...`
  `and so on, were all 0`

.endif

一般来说,你应该优先在源文件中使用 Gas 的条件汇编指令,而不是 CPP 的条件编译语句。只有在处理包含其他 CPP 语句(如 #define 语句)的源代码时,或者在测试是否在 CPP 中使用 #ifdef 或 #ifndef 语句定义了符号时,才使用 CPP 的条件编译语句。你不能通过使用 Gas 的条件汇编语句来测试 CPP 符号是否已定义,因为所有 CPP 符号在 Gas 汇编源文件时都会被扩展(并且不存在)。

紧跟 .if 或 .elseif 后的布尔表达式必须是一个绝对表达式(参见 第 176 页 的“可重定位和绝对表达式”)。在条件汇编表达式中,false 为零结果,true 为其他任何值。

Gas 支持 .if 指令的几个变体:

.ifdef symbol    如果 symbol 在源文件中该点之前已定义,则组装以下部分。CPP 符号(通过 #define 创建)在汇编之前被展开,因此在此指令中的使用可能无法按预期工作。

.ifb text    如果操作数字段为空白,则组装以下部分。通常使用此指令测试一个空白的宏参数(text 通常是一个宏参数名,可能展开为空字符串)。

.ifc text1, text2    比较 text1 和 text2,如果它们相等,则组装以下部分。字符串比较会忽略文本周围的任何空白字符。字符串 text1 包括从 .ifc 后第一个非空白字符到第一个逗号之间的所有字符。字符串 text2 是逗号后(忽略前导空白)到行尾(也忽略行尾空白)之间的所有文本。如果需要在字符串中包含空白字符,可以选择用撇号(单引号)将字符串括起来。通常,你会使用此语句比较两个宏参数展开结果,以检查参数是否相等。

.ifeq 表达式    如果表达式等于 0,则组装以下代码。

.ifeqs "string1", "string2"    如果两个字符串相等,则组装以下代码。字符串必须用双引号括起来。

.ifge 表达式    如果表达式大于或等于 0,则组装以下代码。

.ifgt 表达式    如果表达式大于 0,则组装以下代码。

.ifle 表达式    如果表达式小于或等于 0,则组装以下代码。

.iflt 表达式    如果表达式小于 0,则组装以下代码。

.ifnb text    如果操作数字段不为空白,则组装以下部分。通常使用此指令测试一个非空白的宏参数(text 通常是一个宏参数名)。

.ifnc text1, text2    比较 text1 和 text2,如果它们不相等,则组装以下部分。字符串比较会忽略文本周围的任何空白字符。字符串 text1 包括从 .ifnc 后第一个非空白字符到第一个逗号之间的所有字符。字符串 text2 是逗号后(忽略前导空白)到行尾(也忽略行尾空白)之间的所有文本。如果需要在字符串中包含空白字符,可以选择用撇号(单引号)将字符串括起来。通常,你会使用此语句比较两个宏参数展开结果,以检查参数是否不相等。

.ifndef symbol, .ifnotdef symbol    如果 symbol 在源文件中该点之前未定义,则组装以下部分。注意,CPP 符号(通过 #define 创建)在汇编之前会展开,因此在此指令中的使用可能无法按预期工作。 .ifnotdef 指令是 .ifndef 的同义词。

.ifne expression    当表达式不等于 0 时,组装以下代码。这个指令是 .if 的同义词。

.ifnes "string1", "string2"    当两个字符串不相等时,组装以下代码。这两个字符串必须用双引号括起来。

Gas 中的条件汇编语句可以出现在任何指令助记符合法的地方。通常,它们会单独占一行,尽管在同一行上出现标签也是合法的(尽管不常见)。在这种情况下,标签将与发出代码的下一条指令或指令关联。

正如你所看到的,Gas 提供了多种条件汇编语句,它们比 CPP 的条件编译语句更强大、更灵活。这也是为什么选择使用 Gas 的条件汇编语句而非 CPP 的另一个原因。

13.3.3 编译时循环

与 CPP 不同,Gas 的 CTL 提供了三种循环构造,用于轻松生成数据并展开循环:.rept、.irp 和 .irpc。以下小节将描述这些指令。

13.3.3.1 .rept....endr

.rept 指令会将一块语句重复指定次数。这个编译时循环的语法如下:

.rept `expression`

  `Statements to repeat`

.endr

Gas 将评估表达式,并重复 .rept 和 .endr 指令之间的语句块,重复指定的次数。如果表达式的值为 0,Gas 将忽略所有语句,直到 .endr,不生成任何代码。

以下示例将初始化一个包含 32 个元素的字节数组,值从 0 到 31:

.set  i, 0     // Initialize array element value.
.rept 32
.byte i
.set  i, i + 1 // Increment array element value.
.endr

你在 .rept 循环中不仅限于数据值,还可以使用 .rept 来展开循环:

// Zero out an eight-dword array:

    .set  ofs, 0
    .rept 8
    str   xzr, [x0, #ofs]
    .set  ofs, ofs + 8
    .endr

这相当于以下代码

 str xzr, [x0, #0]
    str xzr, [x0, #8]
    str xzr, [x0, #16]
    str xzr, [x0, #24]
    str xzr, [x0, #32]
    str xzr, [x0, #40]
    str xzr, [x0, #48]
    str xzr, [x0, #56]

它会将循环展开八次。

13.3.3.2 .irp....endr

.irp(不定次重复)循环指令采用以下形式:

.irp `identifier`, `comma-separated-list-of-values`
 .
 .
 .
.endr

这个循环会对逗号分隔的值列表中的每一项重复。在循环体内,你可以通过使用 \identifier 来引用当前值;以下示例

.irp  i, 0, 1, 2, 3
.byte \i
.endr

等同于

.byte 0
.byte 1
.byte 2
.byte 3

它会为每个 .irp 参数展开循环。

13.3.3.3 .irpc....endr

第三个编译时循环构造 .irpc 类似于 .irp,但它处理的是文本字符串,而不是值列表:

.irpc `identifier`, `text`
 .
 .
 .
.endr

在这里,identifier 是一个符号,它将获取指定文本字符串中每个字符的值。注意,text 是一个裸序列的字符;除非你希望 .irpc 循环将这些标点符号与字符串中的其他字符一起处理,否则不要用双引号或单引号括起来。该 .irpc 循环将对字符串中的每个字符执行一次,\identifier 会在每次迭代时扩展为该字符。例如

.irpc x, acde
.byte '\x'
.endr

扩展为如下内容:

.byte 'a'
.byte 'c'
.byte 'd'
.byte 'e'

注意,\identifier 即使在字符和字符串常量中也会展开(就像本例中的 '\x')。在这个例子中,如果你没有把 \x 用单引号括起来,.irpc 循环会展开成

.byte a
.byte c
.byte d
.byte e

如果程序中没有定义符号 a、c、d 和 e,那么就会产生错误(我故意省略了 b,它本应展开为一个分支指令助记符)。

13.3.4 Gas 宏

Gas 通过 .macro 和 .endm 指令提供宏功能。宏定义的语法如下

.macro `identifier` {`parameter_list`}

  `Statements to expand on macro invocation`

.endm

其中 {and} 字符表示参数列表是可选的(你在宏定义中不需要包含这些字符)。以下小节描述了 Gas 宏的各种组成部分,以及与宏相关的重要语义信息。

13.3.4.1 宏参数

宏参数可以采用以下四种形式之一:

identifier    这种第一种形式,仅仅是一个简单的标识符,是最常见的。除非你提供其他三种选项中出现的后缀,否则这种语法告诉 Gas 参数是可选的。如果你在调用宏时没有提供适当的实际参数值,Gas 在宏体中展开参数时会用空字符串(一个空的参数)代替。

identifier=expression    与第一种形式类似,这指定了一个可以是可选的参数,只不过如果宏调用时没有提供参数值,Gas 会将标识符赋值为表达式的值,而不是空字符串。

identifier:req    指定在调用宏时必须提供宏参数;如果缺失,Gas 会返回错误信息。

identifier:vararg    允许使用一个可变参数列表(零个或多个由逗号分隔的参数)。Gas 会将这个宏参数展开为整个值列表,包括分隔这些值的逗号。

在标准 Gas 语法中,宏名称和第一个参数(如果有的话)之间用空格分隔。我发现,在 ARM 汇编器中,偷偷加个逗号也能正常工作(你的实际情况可能有所不同)。

在宏体内,使用形如 \identifier 的标记——其中 identifier 是宏声明的正式参数之一——来展开一个参数。例如,以下宏演示了值参数的展开:

.macro oneByte value
.byte  \value
.endm
 .
 .
 .
oneByte 1  // Expands to .byte 1

一个宏定义可以有零个或多个参数。如果你提供多个参数,必须用逗号分隔每个正式参数。此外,如果你指定了 vararg 参数,它必须是 .macro 语句中声明的最后一个参数。下面是一个稍复杂的宏示例:

.macro bytes yy:req, zz=0, tt, ss:vararg
.byte  \yy
.byte  \zz
.byte  \tt
.byte  \ss
.endm

当你调用这个宏时,必须至少提供一个实际参数(因为 yy 是一个必需的参数)。例如

bytes 5

展开为:

.byte 5
.byte 0  // Because zz expands to 0 by default
.byte    // Argument tt expands to the empty string.
.byte    // Argument ss also expands to the empty string.

注意,如果像 .byte 这样的数据指令没有操作数,Gas 会忽略该语句,并且不会向目标文件生成任何代码。

这是 bytes 的另一个调用,演示了完整的参数展开。

bytes 5, 4, 3, 2, 1, 0

其展开为:

.byte 5        // yy expansion
.byte 4        // zz expansion
.byte 3        // tt expansion
.byte 2, 1, 0  // ss expansion

这个例子运行得很顺利,因为 .byte 指令允许逗号分隔的操作数。然而,如果你想扩展一个不可逗号分隔的可变参数怎么办?请考虑以下宏:

.macro  addVals theVals:vararg
add     x0, x0, #\theVals
.endm

addVals 这样的调用例如

addVals 1, 2

会生成一个错误,因为

add x0, x0, #1, 2

这是语法错误。你可以通过在宏内部使用 .irp 循环来处理一个可变参数,来解决这个问题:

.macro  addVals theVals:vararg
.irp    valToAdd, \theVals
add     x0, x0, #\valToAdd
.endr
.endm

addVals 1, 2 的调用现在会生成以下内容:

add x0, x0, #1
add x0, x0, #2

Gas 会在宏体中的任何地方扩展宏。请考虑以下宏:

.macro select which
lea    x1, var\which
ldr    w1, [x1]
.endm

假设你已经在某个地方定义了 var0var1 符号,调用 select 0 会生成如下内容

lea x1, var0
ldr w1, [x1]

而调用 select 1 会生成如下内容:

lea x1, var1
ldr w1, [x1]

假设你希望提供名称的前缀,而不是后缀,作为这个例子中的宏参数。第一次尝试将不起作用:

.macro select2 which
lea    x1, \whichvar
ldr    w1, [x1]
.endm

问题当然在于,Gas 会将 \whichvar 解释为名为 whichvar 的参数的展开。要将参数名与后面的文本分开,请使用 \() 符号:

.macro select2 which
lea    x1, \which\()var
ldr    w1, [x1]
.endm

现在,像 select2 my 这样的调用会正确扩展为

lea    x1, myvar
ldr    w1, [x1]

这会创建预期中的名称 myvar

13.3.4.2 宏参数与字符串常量

Gas 的宏有一个“特性”,如果你不小心可能会被它咬:如果你将字符串常量作为正式参数传递,Gas 会在扩展该参数时去掉字符串的引号。例如,请考虑以下宏和调用:

.macro myStr theStr
.asciz \theStr
.endm
 .
 .
 .
myStr "hello"

这会扩展为以下内容:

.asciz hello

除非你已经定义了符号 hello 并赋予了适当的值,否则这将生成一个错误。正确的做法如下:

.macro myStr theStr
.asciz "\theStr"
.endm
 .
 .
 .
myStr "hello"

这段代码正确生成了以下内容:

.asciz "hello"

其中一种可能的用途是将包含逗号和空格的参数作为单个参数传递给宏。我将留给你自己去发掘 Gas 中这种“特性”的其他滥用情况。就我个人而言,我认为它是一个 bug,我不敢使用这个功能,因为 Gas 可能会在未来的汇编器版本中移除此行为。

13.3.4.3 递归宏

与 CPP 不同,Gas 完全支持递归宏。请考虑以下示例(摘自 Gas 手册):

.macro  sum from=0, to=5
.long   \from
.ifgt   \to-\from
sum     "(\from+1)",\to
.endif
.endm

形式为 sum 0, 5 的宏调用会生成以下代码:

.long   0
.long   1
.long   2
.long   3
.long   4
.long   5

sum 宏使用条件汇编语句来防止无限递归。虽然你可以通过使用 .rept(或 .irp)指令更容易地遍历五个值,但有时递归比迭代更适合解决问题。

.irp 和 .rept 指令更适合简单的迭代。递归更适合处理作为宏参数传递的递归数据结构,如列表和树,或者如果你需要反转传递给宏的参数(我在下一节给出了这个例子)。

13.3.4.4 .exitm 指令

.exitm 指令允许你提前终止宏的扩展。其语法如下:

.exitm

当 Gas 在宏扩展过程中遇到.exitm 时,它会立即停止扩展并忽略宏体的其余部分。当然,单纯地将 .exitm 指令放在宏体的中间(除非用于测试目的)并不特别有用——既然剩余部分会被忽略,那为什么要写宏体的其余部分呢?相反,您通常会在条件汇编块中找到 .exitm 宏,如下所示:

.macro reverse first, args:vararg
.ifb    \first
.exitm  // Quit recursion if no more arguments.
.endif
reverse \args
.byte   \first
.endm

.exitm 指令在参数列表为空时终止递归。递归调用将传递除第一个参数外的所有参数给 reverse。如您可能已经猜到,这个宏会按反向顺序生成指定为参数的字节到文件中。例如,reverse 0, 1, 2, 3 会生成

.byte 3
.byte 2
.byte 1
.byte 0

反转传递给反向宏的参数。

13.3.4.5 @ 运算符

在宏内部,Gas 会将令牌 @ 转换为一串数字,指定它在汇编过程中扩展的宏总数。您可以将此运算符与 () 令牌一起使用,以创建宏局部符号。以下宏提供了一个简单的例子:

 .macro  lclsym sym
        b       a\()\@
a\()\@:
        .endm

该宏的多次扩展通过在 a 的末尾附加一串数字来生成一个唯一的符号。

13.3.4.6 .purgem 指令

.purgem 指令删除一个已定义的 Gas 宏。它类似于 CPP 的 #undef 语句。通常,如果您尝试重新定义一个 Gas 宏,Gas 会生成一个错误。如果您想重新定义该宏,请使用 .purgem 指令删除该宏。

请注意,如果您尝试清除一个尚未定义的宏,Gas 将会报错。不幸的是,.ifdef(和类似的)条件汇编语句无法识别宏符号;因此,在使用.purgem 指令之前无法检查一个宏是否已定义;您必须确保在使用该指令之前宏符号已经存在。

13.4 aoaa.inc 头文件

在本书中,我在示例中使用了 aoaa.inc 头文件,但并没有讨论它的内容。现在,您已经了解了 CPP 和 Gas 宏以及 CTL 功能,是时候兑现我在第一章中承诺的,逐节解释这个头文件是如何工作的。

我将一一讲解 aoaa.inc 的源代码,逐步注释并解释每个组成部分。第一部分是通常出现在包含文件开头的头部:

// aoaa.inc
//
// "Magic" header file for The Art of ARM Assembly
// that smooths over the differences between Linux
// and macOS
//
// Assertions:
//
// Either isMacOS or isLinux has been
// defined in the source file (using #define) prior
// to including this source file. This source file
// must be included using `#include "aoaa.inc"`
// NOT using the Gas `.include` directive.

首先,aoaa.inc 头文件假设包含 aoaa.inc 的源文件是通过 build 脚本进行汇编的。除了其他内容外,build 脚本会根据您运行 GCC(和 Gas)的操作系统,适当地在 gcc 命令行上包括以下选项之一来汇编源文件:

-D isMacOS=1

-D isLinux=1

由于这些符号是为 CPP 使用而定义的(而非 Gas),源文件必须具有 .S 后缀,并且必须对该文件运行 CPP,这意味着你需要通过运行 gcc 可执行文件来汇编该文件,而不是通过 as(Gas)可执行文件。

源文件的下一部分处理了多次包含的问题:

// aoaa.inc (cont.)
//
#ifndef aoaa_inc
#define aoaa_inc 0

这个 #ifndef 和 #define 序列是防止程序多次包含头文件时出现问题的标准方式。第一次 CPP 包含此文件时,符号 aoaa_inc 尚未定义;因此,CPP 会处理 #ifndef 语句之后的内容。紧接着的语句定义了 aoaa_inc 符号。如果包含了 aoaa.inc 的汇编源文件第二次包含了该文件,aoaa_inc 符号将已定义,CPP 将忽略直到匹配的 #endif(恰好位于源文件末尾)。

如前面的注释所述,你必须通过 CPP 语句 #include 来包含 aoaa.inc 头文件,而不是使用 Gas 的 .include 指令。因为 aoaa.inc 文件包含了几个 CPP 语句(包括 #ifndef),如果通过 .include 包含,CPP 永远无法看到 aoaa.inc 文件。记住,Gas 语句的处理是在 CPP 执行完并退出之后进行的。

接下来,aoaa.inc 头文件为处理 macOS 和 Linux 特定代码设置了多个符号的定义:

// aoaa.inc (cont.)
//
// Make sure all OS symbols are
// defined and only one of them
// is set to 1:

#ifndef isMacOS
    #define isMacOS (0)
#else
    #undef isMacOS
    #define isMacOS (1)
#endif

#ifndef isLinux
    #define isLinux (0)
#else
    #undef isLinux
 #define isLinux (1)
#endif

// Make sure exactly one of the OS symbols is set to 1:

#if (isMacOS+isLinux) != 1
    #error "Exactly one of isMacOS or isLinux," \
                       " must be 1"
#endif

这一块条件编译语句确保了 both isLinux 和 isMacOS 被定义,并且为操作系统赋予了适当的值。build 脚本提供的命令行参数只会定义这两个符号中的一个。这些语句确保了两个符号都被定义,并且赋予了适当的布尔值(0 表示假,1 表示真)。

接下来,aoaa.inc 头文件定义了 macOS 下所需的一些符号:

// aoaa.inc (cont.)
//
// Do macOS-specific stuff here:

#if isMacOS

    // Change all the C global function
    // names to include a leading underscore
    // character, as required by macOS (these
    // definitions allow you to use all the
    // same names in example programs in
    // macOS and Linux). This list includes
    // all the C stdlib functions used by
    // AoAA example code.

    #define asmMain  _asmMain
    #define acos     _acos
    #define asin     _asin
    #define atan     _atan
    #define cos      _cos
    #define exp      _exp
    #define exp2     _exp2
    #define getTitle _getTitle
    #define free     _free
    #define log      _log
    #define log2     _log2
    #define log10    _log10
    #define malloc   _malloc
    #define pow      _pow
    #define printf   _printf
    #define readLine _readLine
    #define sin      _sin
    #define sqrt     _sqrt
    #define strcat   _strcat
    #define strchr   _strchr
 #define strcmp   _strcmp
    #define strcpy   _strcpy
    #define strlen   _strlen
    #define strncat  _strncat
    #define strncpy  _strncpy
    #define strstr   _strstr
    #define strtol   _strtol
    #define tan      _tan
    #define write    _write

    #define __errno_location ___error

在 macOS 下,像 C 标准库函数名称这样的外部符号有一个前导下划线。而在 Linux 下,这些符号没有下划线。前面的代码片段中的 #define 语句将一些常见的 C 标准库函数名称替换成了带下划线前缀的版本。这使得本书中的函数调用在 macOS 和 Linux 下都能使用一致的名称。

这些定义仅适用于出现在此列表中的 C 标准库函数。如果你决定调用其他标准库函数(或使用其他外部符号),你需要显式地为其提供下划线前缀字符,或将额外的 #define 语句添加到此列表中。

lea 宏也有一个特定于 macOS 的实现:

// aoaa.inc (cont.)
//
// lea (Load Effective Address) macro.
// Correctly loads the address of
// a memory object into a register, even
// on machines that use position-independent
// executables (PIE):

.macro  lea, reg, mem
    adrp    \reg,\mem@PAGE
    add     \reg, \reg, \mem@PAGEOFF
.endm

如 第一章 和 第七章 中所述,lea 宏展开为两条指令,这些指令将符号的地址加载到一个 64 位寄存器中。包含这个宏(而不是在本书中每次出现 lea 时显式地写出这两条指令)的主要原因是,这两条指令略有不同,具体取决于代码是为 macOS 还是 Linux 汇编的。这个版本的 lea 宏生成了 macOS 的代码。

接下来是 mstr、mstrb 和 mstrh 宏,它们也有 macOS 特定的实现:

// aoaa.inc (cont.)
//
// mstr Assembles to a str instruction under macOS
// mstrb
// mstrh

.macro      mstr, operands:vararg
str         \operands
.endm

.macro      mstrb, operands:vararg
strb        \operands
.endm

.macro      mstrh, operands:vararg
strh        \operands

Linux 和 macOS 对变长参数列表的处理方式不同。在 Linux 下,你继续将前八个参数通过 X0 到 X7 传递,而在 macOS 下,你同时通过寄存器和堆栈传递它们。mstr、mstrb 和 mstrh 宏在 macOS 下展开为将寄存器存储到堆栈上的代码(稍后你将看到,Linux 版本展开后什么也不做)。

Clang 汇编器(macOS 版的 Gas)不支持 .dword 指令;以下宏为 macOS 实现了这一点。因此,在 macOS 下,aoaa.inc 头文件包含一个宏来提供这个缺失的指令,并将其映射到等效的 .quad 指令:

// aoaa.inc (cont.)
//
// macOS's assembler doesn't have .dword,
// define it here:

.macro  .dword, value:vararg
    .quad   \value
.endm

早期章节使用 vparmn 宏将变量传递给 printf() 函数。由于变长参数的 API 在 macOS 和 Linux 之间有所不同,因此对这两个操作系统有不同的定义。这是它们的 macOS 实现:

// aoaa.inc (cont.)
//
// Macros to load parameters 2..8 onto
// the stack for macOS when calling
// a variadic (variable parameter list)
// function, such as printf().
//
// Note that parameter 1 still goes into X0.

.macro  vparm2, mem
lea     x1, \mem
ldr     x1, [x1]
str     x1, [sp]
.endm

.macro  vparm3, mem
lea     x2, \mem
ldr     x2, [x2]
str     x2, [sp, #8]
.endm

.macro  vparm4, mem
lea     x3, \mem
ldr     x3, [x3]
str     x3, [sp, #16]
.endm

.macro  vparm5, mem
lea     x4, \mem
ldr     x4, [x4]
str     x4, [sp, #24]
.endm

.macro  vparm6, mem
lea     x5, \mem
ldr     x5, [x5]
str     x5, [sp, #32]
.endm

.macro  vparm7, mem
lea     x6, \mem
ldr     x6, [x6]
str     x6, [sp, #40]
.endm

.macro  vparm8, mem
lea     x7, \mem
ldr     x7, [x7]
str     x7, [sp, #48]
.endm        .endm

接下来是这些宏的 Linux 特定实现:

// aoaa.inc (cont.)

#elif isLinux == 1

    // Do Linux (no-PIE)-specific stuff here:

    .macro  lea, reg, mem
        adrp    \reg,\mem
        add     \reg, \reg, :lo12:\mem
    .endm

    // mstr  assembles to nothing under Linux
    // mstrb
    // mstrh

    .macro      mstr, operands:vararg
    .endm

 .macro      mstrb, operands:vararg
    .endm

    .macro      mstrh, operands:vararg
    .endm

    .macro  vparm2, mem
    lea     x1, \mem
    ldr     x1, [x1]
    .endm

    .macro  vparm3, mem
    lea     x2, \mem
    ldr     x2, [x2]
    .endm

    .macro  vparm4, mem
    lea     x3, \mem
    ldr     x3, [x3]
    .endm

    .macro  vparm5, mem
    lea     x4, \mem
    ldr     x4, [x4]
    .endm

    .macro  vparm6, mem
    lea     x5, \mem
    ldr     x5, [x5]
    .endm

    .macro  vparm7, mem
    lea     x6, \mem
    ldr     x6, [x6]
    .endm

    .macro  vparm8, mem
    lea     x7, \mem
    ldr     x7, [x7]
    .endm

#endif      #endif

对于 stdlib 函数没有 #define 语句,因为 Linux 不要求外部名称带下划线前缀字符。至于与参数相关的函数,Linux 仅通过寄存器传递变长参数列表的前八个参数,而不是通过堆栈。因此,mstr、mstrb 和 mstrh 宏展开后什么也不做,而 vparmn 宏则展开为不将数据存储在堆栈上的代码。

源文件的其余部分对 macOS 和 Linux 都是通用的。首先,aoaa.inc 头文件包含一些 .global 指令,用于为调用汇编文件的 C/C++ 程序指定公共名称:

// aoaa.inc (cont.)
//
// Global declarations:

.global asmMain
.global getTitle
.global readLine
.global printf

printf 的定义严格来说并不是必需的;它实际上只是一个外部声明,而未定义的符号默认就是外部符号。我之所以添加它,是因为本书几乎每个示例程序都会调用 printf() 函数。

Gas 实际上并不提供 .qword 指令。 .qword 宏将 .octa 重命名为 .qword,以便与 .word 和 .dword 保持一致:

// aoaa.inc (cont.)
//
// Generic code for all OSes:

// Gas doesn't have a .qword
// directive. Map .qword to .octa:

.macro  .qword, value:vararg
    .octa   \value
.endm

接下来是 aoaa.inc 中结构体定义宏所需的定义:

// aoaa.inc (cont.)
//
// Macros for structure definitions:

__inStruct          = 0
__inArgs            = 0
__inLocals          = 0
__dir               = 1

__inStruct、__inArgs、__inLocals 和 __dir 编译时变量维护着声明结构字段、参数和局部变量所需的信息,使用 struct、args 和 locals 宏。__in* 变量是布尔值,用于跟踪程序当前是否在定义结构、参数列表或一组局部变量。一次只能有一个字段包含 true(非零),但如果不声明这些对象的字段,则它们都可以是 0。

__dir 变量的值要么是 1,要么是 -1。它决定了这些对象中连续声明的偏移量是递增(当 __dir 为 +1 时)还是递减(当 __dir 为 -1 时)。结构体和参数的偏移量是递增的,而局部变量的偏移量是递减的。

这些编译时常量设置完毕后,接下来是实际的 struct、args 和 locals 宏:

// aoaa.inc (cont.)

                ❶ .macro  struct  name, initialOffset=0
__inStruct          = 1
__inLocals          = 0
__inArgs            = 0
__struct_offset     = \initialOffset
\name\().base       = \initialOffset
__dir               = 1
                    .if     \initialOffset > 0
                    .err
                    error   struct offset must be negative or 0
                    .endif
                    .endm

                ❷ .macro  args  name, initialOffset=16
__inStruct          = 0
__inLocals          = 0
__inArgs            = 1
__struct_offset     = \initialOffset
\name\().base       = \initialOffset
__dir               = 1
                    .endm
                ❸ .macro  locals  name
__inStruct          = 0
__inLocals          = 1
__inArgs            = 0
__struct_offset     = 0
__dir               = -1
                    .endm

struct、args 和 locals 宏允许你定义结构体(记录)、参数列表(参数)和局部变量。这些宏设置了一些编译时变量,用于跟踪对象的基地址,以及分配给对象字段的偏移量的方向(正向或负向)。

struct 宏 ❶ 通过将字段偏移与结构体的每个成员关联来创建结构体(记录)。struct 宏本身仅初始化 __inStruct、__dir 和 name.base 这三个编译时变量,它们保持声明结构体字段时所需的信息(其中 name 是用户提供的结构体名称)。__struct_offset CTL 维护结构体内的“位置计数器”。默认情况下,struct 宏将其初始化为 0。然而,调用 struct 的人可以指定一个负值,如果他们希望指定结构体的第一个字段在内存中出现在结构体基地址之前。__dir CTL 被初始化为 1,因为结构体中的连续字段在结构体内有递增的偏移量。

args 宏 ❷ 声明函数或过程的参数列表,本质上与创建结构体相同;毕竟,你是在定义激活记录的一部分。唯一的真正区别是起始偏移量为 16(这是在激活记录中第一个参数的偏移量,使用 FP 寄存器指定的基地址;保存的 FP 值和返回地址分别占用偏移量为 0 和 8 的双字)。因为参数在较高地址处,因此 __dir 字段被初始化为 1。

locals 宏 ❸ 声明在堆栈上分配的局部变量,这些变量位于 FP 寄存器所持有的地址下方。由于连续声明的变量会出现在内存中的较低地址处,因此该宏将 __dir 字段初始化为 -1。

与匹配的结束语、enda 和 endl 语句相关的宏稍后会出现在列表中。接下来的部分描述了可以出现在结构体内部的数据声明宏:

// aoaa.inc (cont.)

                    .macro  salign, size
__salign        = 0xFFFFFFFFFFFFFFFF - ((1 << \size)-1)
__struct_offset = (__struct_offset + (1 << \size)-1) & __salign
                    .endm

salign 宏只能出现在 struct、args 或 locals 声明中,它调整 __struct_offset 值(位置计数器),使其在一个是 2 的幂次方的偏移量上对齐(2 的幂次方由参数指定)。这个宏通过创建一个位掩码来实现其目的,该位掩码在低位包含 0,在剩余的高位包含 1。将 __struct_offset 与此值进行逻辑与运算,产生一个对齐到设计值的偏移量。

以下宏提供了用于结构体中的 byte、hword、word、dword、qword、oword、single 和 double 指令:

// aoaa.inc (cont.)

                    .macro  byte, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + \elements
                    .else
__struct_offset     =       __struct_offset + \elements
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  hword, name, elements=1
 .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*2)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*2)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  word, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*4)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*4)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  dword, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*8)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*8)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  qword, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*16)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*16)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  oword, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*32)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*32)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  single, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*4)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*4)
\name               =       -__struct_offset
                    .endif
                    .endm

                    .macro  double, name, elements=1
                    .if     __dir > 0
\name               =       __struct_offset
__struct_offset     =       __struct_offset + ((\elements)*8)
                    .else
__struct_offset     =       __struct_offset + ((\elements)*8)
\name               =       -__struct_offset
                    .endif
                    .endm

每个宏声明一个指定类型的单一标量或数组变量(可以通过提供第二个参数并指定元素个数来定义数组)。

这些宏会将当前位置计数器 __struct_offset 增加到变量的大小,并将该偏移量赋值给声明的名称。如果 __dir 为负(局部声明),宏会先递减位置计数器,然后将偏移量赋给名称;如果 __dir 为正,宏会将偏移量赋给名称,并增加位置计数器的值。

以下是 endsendaendl 宏:

// aoaa.inc (cont.)
//
// Generate name.size and name.offset constants
// specifying total structure size and the offset
// just beyond the last field.
//
// Also create a macro to be used to declare
// structure variables.

                    .macro ends, name
__inStruct          =      0
\name\().size       =      __struct_offset-\name\().base
\name\().offset = __struct_offset
                    .macro  \name, varName
                    .if     \name\().base < 0
                    .space  __struct_offset-(\name\().base)
                    .endif
\varName:
                    .if     __struct_offset > 0
                    .fill   __struct_offset
                    .endif

                    .endm
                    .endm
                    .macro enda, name
__inArgs            =      0
\name\().size       =      __struct_offset-\name\().base
                    .endm

                    .macro endl, name
__inLocal           =      0
\name\().size       =      __struct_offset
                    .endm

endsendaendl 宏完成由 structargslocals 开始的声明。它们将跟踪打开的结构、参数列表或局部变量声明的布尔变量设置为 false,然后将 name.size 等式设置为声明的总大小。ends 宏还定义了一个宏,你可以在代码中使用它来声明结构对象。

wastr 宏将一个字对齐的字符串输出到内存中。以下是它的实现:

// aoaa.inc (cont.)
//
// Macro to emit a string that is padded with bytes
// so that it consumes a multiple of 4 bytes in memory:

                    .macro   wastr, theStr
                    .asciz   "\theStr"
                    .p2align 2
                    .endm

这个宏主要用于 .text 区段,因为你必须确保所有代码和标签在这些区段内都保持字对齐。宏参数展开时必须使用引号包围,因为 Gas 会去掉你在实际参数中提供的引号(请参见第 13.3.4.2 节,“带字符串常量的宏参数”,第 768 页)。

procendp 宏为在汇编语言源文件中声明过程提供了语法糖。以下是它们的实现:

// aoaa.inc (cont.)
//
// Macros for declaration procedures/functions:

public              =       1
                    .macro  proc pName:req, isPublic=0

// If "public" argument is present, emit
// global statement.

                    .if     \isPublic
                    .global _\pName
                    .global \pName
                    .endif

\pName\().isOpenProcDCL = 1
\pName:
_\pName:
                    .endm

 .macro  endp pName:req
                    .ifndef \pName\().isOpenProcDCL
                    .err
                    .err    "Not an open procedure"
                    .else
                    .if     \pName\().isOpenProcDCL
                    .else
                    .err
                    .err    "endp name does not match last proc name"
                    .endif
                    .endif
\pName\().isOpenProcDCL = 0
                    .endm

除了输出过程名称和(如果 isPublic 为 1).global 指令外,这个宏实际上并不做什么。

public 等式允许你将 public 作为第二个参数传递给此宏,以告诉汇编器将符号设为全局(即公共)。从技术上讲,你可以直接传递 1 作为第二个参数,但 public 更具可读性。

.code 宏简单地展开为 .text,并确保位置计数器对齐到字(4 字节)边界:

// aoaa.inc (cont.)
//
// Sanity for ARM code:

                    .macro  .code
                    .text
                    .align  2
                    .endm

enterleave 宏提供了过程的标准入口和标准退出序列(有关更多详情,请参见第 5.4.4 节,“标准入口序列”,第 248 页,以及第 5.4.5 节,“标准退出序列”,第 250 页):

// aoaa.inc (cont.)
//
// Assembly standard entry sequence:

    .macro  enter, localsSize
    stp     fp, lr, [sp, #-16]!
    mov     fp, sp
    .if     \localsSize > 0
    sub     sp, sp, #((\localsSize)+15) & 0xFFFFFFFFFFFFFFF0
    .endif
    .endm

// Assembly standard exit sequence:

    .macro  leave
    mov     sp, fp
 ldp     fp, lr, [sp], #16
    ret
    .endm

在极少数情况下,b(分支)和 b.al(始终分支)指令可能会在目标位置距离指令过远时生成超出范围的错误。在这种情况下,你可以使用 goto 宏将控制转移到 ARM CPU 64 位地址范围内的任何位置:

// aoaa.inc (cont.)
//
// goto
//
// Transfers control to the specified label
// anywhere in the 64-bit address space:

            .macro  goto, destination
            adr     x16, 0f
            ldr     x17, 0f
            add     x16, x16, x17
            br      x16
0:
            .dword  \destination-0b
            .endm

goto 宏修改了 X16X17 寄存器中保存的值。ARM API 将这两个寄存器专门保留用于此目的,因此这是允许的。然而,你应该始终记住这个宏会修改 X16X17

C 标准库提供了魔法指针 __errno_location,它返回指向 C 的 errno 变量的指针,并将其存储在 X0 中。getErrno 宏展开为一个函数调用,该函数获取此值并将其返回在 W0 中:

// aoaa.inc (cont.)
//
// getErrno
//
// Retrieves C errno value and returns
// it in X0:

            .extern __errno_location
            .macro  getErrno
            bl      __errno_location
            ldr     w0, [x0]
            .endm

ccneccnle 等式定义了供 ccmp 指令使用的有用位模式:

// aoaa.inc (cont.)
//
// Constants to use in the immediate field of
// ccmp:

//          NZCV
    .equ    ccne,   0b0000          // Z = 0
    .equ    cceq,   0b0100          // Z = 1
    .equ    cchi,   0b0010          // C = 1
    .equ    cchs,   0b0110          // Z = 1, C = 1
    .equ    cclo,   0b0000          // Z = 0, C = 0
    .equ    ccls,   0b0100          // Z = 1, C = 0
    .equ    ccgt,   0b0000          // Z = 0, N = V
    .equ    ccge,   0b0100          // Z = 1, N = V
    .equ    cclt,   0b0001          // Z = 0, N! = V
    .equ    ccle,   0b0101          // Z = 1, N! = V

    .equ    cccs,   0b0010          // C = 1
    .equ    cccc,   0b0000          // C = 0
    .equ    ccvs,   0b0001          // V = 1
    .equ    ccvc,   0b0000          // V = 0
    .equ    ccmi,   0b1000          // N = 1
    .equ    ccpl,   0b0000          // N = 0

    .equ    ccnhi,  0b0100          // Not HI = LS, Z = 1, C = 0
    .equ    ccnhs,  0b0000          // Not HS = LO, Z = 0, C = 0
    .equ    ccnlo,  0b0110          // Not LO = HS, Z = 1, C = 1
    .equ    ccnls,  0b0010          // Not LS = HI, C = 1

    .equ    ccngt,  0b0101          // Not GT = LE, Z = 1, N! = V
    .equ    ccnge,  0b0001          // Not GE = LT, Z = 0, N! = V
    .equ    ccnlt,  0b0100          // Not LT = GE, Z = 1, N = V
    .equ    ccnle,  0b0000          // Not LE = GT, Z = 0, N = V

在编写代码模拟类似 HLL 的控制结构(例如 if/then/else 语句)时,对立分支非常有用:

// aoaa.inc (cont.)
//
// Opposite conditions (useful with all conditional instructions)

#define nhi ls
#define nhs lo
#define nlo hs
#define nls hi
#define ngt le
#define nge lt
#define nlt ge
#define nle gt

// Opposite branches

        .macro  bnlt, dest
        bge     \dest
        .endm

        .macro  bnle, dest
        bgt     \dest
        .endm

        .macro  bnge, dest
        blt     \dest
        .endm

        .macro  bngt, dest
        ble     \dest
        .endm

        .macro  bnlo, dest
        bhs     \dest
        .endm

        .macro  bnls, dest
        bhi     \dest
        .endm

        .macro  bnhs, dest
        blo     \dest
        .endm

        .macro  bnhi, dest
        bls     \dest
        .endm
#endif // aoaa_inc

endif 语句终止了源文件开头的 #ifndef 语句。

13.5 通过另一个宏生成宏

你可以使用一个宏来编写另一个宏,以下代码演示了这一点:

// Variant of the proc macro that deals
// with procedures that have varying
// parameter lists. This macro creates
// a macro named "_`name`" (where `name` is
// the procedure name) that loads all
// but the first parameters into registers
// X1..X7 and stores those values onto
// the stack.
//
// Limitation: maximum of seven arguments

        .macro  varProc pName:req

// Create a macro specifically for this func:

        .macro  _\pName parms:vararg
reg     =       1
        .irp    parm, \parms
        .irpc   rnum, 1234567
        .if     reg==\rnum
        lea     x\rnum, \parm
        ldr     x\rnum, [x\rnum]
        mstr    x\rnum, [sp, #(reg-1)*8]
        .endif
        .endr
reg     =       reg + 1
        .endr
        bl      \pName
        .endm

// Finish off the varProc macro (just like
// the proc macro from aoaa.inc):

\pName\().isOpenProcDCL = 1
\pName:
        .endm

正如注释所述,这种类型的 proc 宏创建了一个新的 varProc 宏,你可以用它来通过 HLL 类语法调用过程。考虑以下对该宏的调用:

// Demonstrate the varProc macro
// (creates a macro name _someFunc
// that will load parameters and
// then branch to printf):

        varProc someFunc
        b       printf
        endp    someFunc

这将扩展为以下代码:

 .macro _someFunc, parms:vararg
reg     =       1
        .irp    parm, \parms
        .irpc   rnum, 1234567
        .if     reg==\rnum
        lea     x\rnum, \parm
        ldr     x\rnum, [x\rnum]
        mstr    x\rnum, [sp, #(reg-1)*8]
        .endif
        .endr
reg     =       reg + 1
        .endr
        bl      someFunc
        .endm

按如下方式调用 _someFunc 宏:

 lea x0, fmtStr
    _someFunc i, j

这将生成以下代码:

 lea x0, fmtStr

// Macro expansion:

    lea x1, i
    ldr x1, [x1]
    lea x2, j
    ldr x2, [x2]
    bl  someFunc

然后生成一些 func 分支到 printf,实际上将其转化为对 printf() 函数的调用。

你本可以编写一个宏直接调用 printf()(处理参数),但你需要为每个想要使用 HLL 类语法调用的函数编写这样一个宏。让 varProc 宏自动编写这个宏可以让你省去重复的工作。

varProc 宏有一个严重的限制,它的参数必须是全局内存位置(不能是寄存器、局部变量或其他类型的内存操作数)。尽管这个宏可能并不是特别有用,但它展示了一个宏如何编写另一个宏。我将把扩展这个宏以处理其他类型操作数的任务留给你作为练习。

请注意,如果一个宏创建另一个宏,它在创建新宏时必须使用未定义的名称。本节中的示例通过让调用者将新宏的名称作为参数传递给创建新宏的宏来实现这一点。也可以使用 .purgem 指令在创建新宏之前删除其名称。但是,请记住,在使用 .purgem 删除宏名称时,该宏名称必须已经存在。在第一次调用创建宏时,这可能会成为问题,因为第一次调用时要创建的宏可能不存在。可以通过在创建宏的第一次调用之前提供一个空宏来轻松解决这个问题:

.macro  createdMacro
  `Empty body`
.endm

.macro  createMacro

.purgem createdMacro
.macro  createdMacro

  `Macro body`

.endm // createdMacro
  .
  .
  .
.endm

因为 createdMacro 在第一次调用 createMacro 时已经存在,所以 .purgem 语句不会生成错误信息。在第一次调用后,未来的 createMacro 调用将删除前一次调用中创建的 createdMacro 版本。

13.6 选择 Gas 宏和 CPP 宏

看一眼 GNU CPP 文档(<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/onlinedocs<wbr>/cpp<wbr>/),你会发现 GNU 的开发者建议使用 Gas 内置的宏功能,而不是 CPP。如果编写 CPP 和 Gas 的人建议使用 Gas 宏处理器而非 CPP,难道你不应该认真考虑这个建议吗?

如果你只能使用一个宏处理器,那么你可以强有力地证明选择 Gas 宏处理器而非 CPP 更为合适。CPP 不是一个非常强大的宏处理器,C/C++ 程序员滥用宏使它名声不好。在许多方面,Gas 的宏处理器明显优于 CPP。Gas 的宏功能在两者之间会是更好的选择。

然而,谁说你只能使用其中一个呢?为什么不同时使用两个呢?CPP 和 Gas 各有优缺点,通常是互补的。虽然 Gas 的宏功能比 CPP 强大,但与其他汇编器相比——例如微软的宏汇编器(MASM)或高级汇编器(HLA)——Gas 的宏功能并不特别出色。任何能够增强 Gas 宏功能的工具都是好的。CPP 也有一些 Gas 所缺乏的优点(比如函数式宏调用),而 Gas 当然也有 CPP 不具备的许多功能(比如多行宏定义)。如果你小心使用,结合使用两个宏处理器会给你带来超越任何一个宏处理器的能力。这是好事,所以我全力推荐将 CPP 和 Gas 的力量结合起来。

考虑到在编译 Gas 源文件时你有两个 CTL 可用(至少在使用 .S 后缀时),你应该使用哪种 CTL 结构?大多数情况下,这不太重要;如果两个 CTL 都能工作,选择取决于你,不过如果其他因素相同,坚持使用 Gas 的 CTL 可能是最安全的选择。然而,由于两者具有不同的能力,有时你可能需要选择其中一个而非另一个。

例如,CPP 的宏定义看起来像函数,并且几乎可以出现在源文件中的任何地方(除了注释之外)。它们非常适合编写地址表达式函数。以下代码演示了使用函数式 CPP 宏的方式:

mov x0, #cppMacro(0, x)

在这个例子中,Gas 宏不起作用,因为 Gas 宏不支持函数式调用。

另一方面,CPP 宏只能生成一行文本。因此,当你想要发出一系列指令时,Gas 宏是必要的:

lea x0, someLabel  // Expands to two instructions!

另外,请注意,如果你在宏展开中尝试使用 # 符号(在 CPP 中是字符串化,Gas 中是立即操作数),CPP 宏可能会给你带来问题。

Gas 还支持一组更丰富的条件汇编语句,以及 CTL 循环语句。这使得 Gas 更适合于发出大量数据或大量语句的宏。对于简单的地址表达式函数,我通常偏好使用 CPP 宏,而在需要将宏扩展为实际语句时,我会使用 Gas 宏。

在简单常量声明中,决定是使用 CPP 语句还是 Gas 的.set、.equ 和=指令并不是很明确。对于简单的整数常量,Gas 的 equate 指令工作得很好。对于非整数值,CPP 表现得更好。以下示例演示了如何使用 CPP 宏定义字符串常量:

#define aStr "Hello, World!"       // This works.
//      .set aStr, "Hello, World!" // This does not.

        .asciz aStr

对于整数表达式,Gas 的 equate 指令通常表现更好:

// #define a (a+1)  // Generally doesn't work as expected
    .set a, a+1     // Works fine (assuming some previous definition of a)

最后,始终记住,CPP 在汇编过程之前会先处理它的 CTL 语句,意味着 CPP 并不知道汇编语言源文件中特定的符号和其他标记。例如:

a:   .byte 0
      .
      .
      .
#ifdef a    // a is undefined to CPP; it's a Gas symbol.
 .
 .
 .
#endif

 .ifdef a  // a is defined to Gas's conditional assembly.
     .
     .
     .
    .endif

ifdef 符号会认为符号 a 未定义(即使它在 Gas 源文件中已经定义)。记住,CPP 条件编译语句只知道通过#define 语句创建的符号。

13.7 继续前进

Gas 和 CPP 的 CTL 极大地扩展了 Gas 汇编器的功能。使用这些功能,包括常量定义、宏定义、条件编译和汇编等,可以减少你编写汇编语言源代码所需的工作量。

本章介绍了你在汇编语言源文件中使用 Gas 和 CPP CTL 所需的基本信息,首先讨论了 CPP。第二部分讨论了 Gas CTL,包括错误和警告指令、条件汇编、编译时循环指令和 Gas 宏。接下来,本章描述了你从第一章开始广泛使用的aoaa.inc头文件的内部源代码。最后,本章对比了 CPP CTL 和 Gas 宏功能,讨论了何时选择一个系统而不是另一个。

现在这本书已经介绍了 Gas CTL,接下来的示例代码将开始使用宏功能,从下一章开始。

13.8 更多信息

第十四章:14 字符串操作

字符串是存储在连续内存位置上的一组值。字符串通常是字节、半字、字、双字或四字的数组。一些 CISC CPU,如 Intel x86-64,支持直接操作字符串数据的指令。然而,ARM 没有提供这类指令,因为字符串操作往往很复杂,并且违反了 RISC 设计准则。不过,使用离散的 ARM 指令仍然可以操作字符串数据结构。

尽管 ARM CPU 不支持字符串特定的指令,但字符串操作仍然是 CPU 必须执行的重要功能。本章讨论了在使用 ARM 汇编语言时如何处理字符串。首先,本章介绍了如何调用 C 标准库中的函数来实现字符串操作。这些函数编写得很好(通常是用汇编语言编写),并提供了高性能的实现,只要你使用的是零终止字符串。然而,如本书中所述,零终止字符串并不是高性能字符串操作最适合的字符串数据结构。因此,本章描述了一种更好的字符串格式,使你能够编写更快速的字符串函数。

当然,采用新的字符串实现问题在于 C 标准库函数不支持它,因此本章还描述了如何实现支持新字符串格式的各种字符串函数。最后,本章通过讨论 Unicode 字符串和字符串函数来作总结。

14.1 零终止字符串与函数

第四章简要介绍了字符串数据类型,讨论了零终止字符串(常用于 C/C++及其衍生语言)、长度前缀字符串、字符串描述符和其他字符串形式。如前所述,零终止字符串是当前最常用的字符串形式。特别是,Linux(Pi OS)和 macOS 通常在 API 函数中使用零终止字符串来传递或接收字符串数据。因此,在这些操作系统下运行的 ARM 汇编语言程序中,你将经常使用零终止字符串。本节描述了零终止字符串的问题以及如何调用支持零终止字符串的 C 标准库函数。

零终止字符串的主要问题是性能。这些字符串通常需要扫描每个字符才能执行简单的操作,比如计算字符串的长度。例如,以下代码计算名为 longZString 的字符串的长度:

 lea   x1, longZString
          mov   x2, x1         // Save pointer to string.
whileLp:  ldrb  w0, [x1], #1   // Fetch next char and inc X1.
          cmp   w0, #0         // See if 0 byte.
          bne   whileLp        // Repeat while not 0.
          sub   x0, x1, x2     // X0 = X1 - X2
          sub   x0, x0, #1     // Adjust for extra increment.

// String length is now in X0.

如果 longZString 确实非常长,那么这段代码序列可能需要很长时间才能执行。

长度前缀字符串(参见第 4.6.2 节,“长度前缀字符串”,在 第 188 页)通过将字符串的当前长度与数据一起包含来解决这个问题。任何使用字符串长度的字符串函数将更高效地操作,因为它不必首先扫描整个字符串来确定其长度。如果你能够选择在汇编语言代码中使用的字符串格式,选择一个将长度作为字符串数据一部分的数据类型,可以显著提高字符串处理性能。

不幸的是,你并不总是能选择代码中使用的字符串格式。有时,外部数据集、应用程序或操作系统会强制你使用以零结尾的字符串格式(例如,操作系统的 API 调用通常要求使用以零结尾的字符串)。

有可能提高像之前给出的字符串长度代码这样的简单字符串函数的性能。特别是,whileLp 中的代码每次循环迭代时处理一个字节。由于 ARM64 CPU 通常能够在通用寄存器中一次处理 8 个字节(在向量寄存器中一次处理 16 个字节),你可能会想,是否可以比每次循环迭代处理一个字符做得更好。毕竟,如果你每次循环迭代处理 16 个字节(而不是 1 个字节),函数应该运行快 16 倍,对吧?

答案是有条件的肯定。第一个警告是每次迭代处理 16 字节更复杂,需要循环体中超过三条指令。因此,期望提升 16 倍是过于乐观的;4 到 8 倍的提升可能是更合理的期望,但仍然值得追求。

第二个警告是每次处理 16 个字符需要在每次迭代时从内存加载 16 个字节,这意味着对于许多字符串,你将不得不从字符串的末尾之后的内存中读取数据。因此,可能会读取超过包含字符串的 MMU 页的末尾,这可能导致内存保护错误(参见第 3.1.7 节,“内存访问和 MMU 页”,在 第 127 页)。虽然这种情况很少发生,但它仍然代表了代码中的一个缺陷,可能会导致你的应用程序崩溃。

最后一个问题,尽管它不像非法内存访问那样致命,但从内存加载 16 个字节的数据到向量寄存器中时,如果数据按照 16 字节边界对齐,则效果最佳。不幸的是,以零结尾的字符串在内存中并不能保证从这样的边界开始。

如果你完全控制字符串在内存中的位置,你可以确保字符串总是从 16 字节边界开始。同样,你也可以在字符串末尾始终添加填充,以确保至少能读取字符串数据末尾之后的 15 个字节,从而避免 MMU 页面边界的问题。不幸的是,很少有程序能够对其字符串有如此严格的控制,以确保内存中的这种安排。例如,如果操作系统返回指向字符串的指针,它可能会违反对齐和填充要求。

一般来说,如果你要操作以零结尾的(C)字符串,我建议调用 C 标准库函数。在过去,C 标准库函数是用 C 编写的,即使使用最好的优化编译器,也足够简单,好的汇编语言程序员能够编写比编译器生成的代码更快的代码。然而,现代 C 标准库的字符串代码通常是用汇编语言编写的(由专家程序员编写),通常比你自己写的任何代码都要好。例如,GNU C 标准库的 AARCH64 版本中,以下函数是用手写汇编语言编写的(有关更多信息,请参见第 859 页,在“更多信息”一节中):

strcpy

strchr

strchrnul

strcmp

strcpy

strlen

strncmp

strnlen

strrchr

以下是 GNU C 标准库的strlen.S源文件(稍作修改以便格式化和注释):

// strlen.S
//
// Copyright (C) 2012-2022 Free Software Foundation, Inc.
// This file is part of the GNU C Library. The GNU C
// Library is free software; you can redistribute it
// and/or modify it under the terms of the GNU Lesser
// General Public License as published by the Free
// Software Foundation; either version 2.1 of the License,
// or (at your option) any later version. The GNU C
// Library is distributed in the hope that it will be
// useful, but WITHOUT ANY WARRANTY; without even the
// implied warranty of MERCHANTABILITY or FITNESS FOR A
// PARTICULAR PURPOSE. See the GNU Lesser General Public
// License for more details. You should have received a
// copy of the GNU Lesser General Public License along
// with the GNU C Library. If not, see
// <https://www.gnu.org/licenses/>.

#include <sysdep.h>

// Assumptions:
//
// ARMv8-a, AArch64, Advanced SIMD
// MTE compatible

❶ #define srcin       x0
#define result      x0

#define src         x1
#define synd        x2
#define tmp         x3
#define shift       x4

#define data        q0
#define vdata       v0
#define vhas_nul    v1
#define vend        v2
#define dend        d2

// Core algorithm: For each 16-byte chunk, calculate a
// 64-bit nibble mask value with 4 bits per byte. This code
// take 4 bits of every comparison byte with shift right
// and narrow by 4 instruction. Since the bits in the
// nibble mask reflect the order in which things occur in
// the original string, counting trailing 0s identifies
// exactly which byte matched.

// On input, X0 contains a pointer to a zero-terminated string.
// On return, X0 contains the string length.

STRLEN:
      ❷ bic     src, srcin, 15
        ld1     {vdata.16b}, [src]
        cmeq    vhas_nul.16b, vdata.16b, 0
        lsl     shift, srcin, 2
        shrn    vend.8b, vhas_nul.8h, 4     /* 128 -> 64 */
        fmov    synd, dend
        lsr     synd, synd, shift
        cbz     synd, zloop

      ❸ rbit    synd, synd
        clz     result, synd
        lsr     result, result, 2
        ret

        .p2align 5
zloop:
      ❹ ldr     data, [src, 16]!
        cmeq    vhas_nul.16b, vdata.16b, 0
        umaxp   vend.16b, vhas_nul.16b, vhas_nul.16b
        fmov    synd, dend
        cbz     synd, zloop

      ❺ shrn    vend.8b, vhas_nul.8h, 4     /* 128 -> 64 */
        sub     result, src, srcin
        fmov    synd, dend
        rbit    synd, synd
        clz     tmp, synd
        add     result, result, tmp, lsr 2
        ret

这些宏定义❶为函数中的寄存器赋予了有意义的名称。就个人而言,我更倾向于看到带有描述寄存器内容的注释的寄存器名称,而不是重新定义的寄存器,这样可以更容易避免重复使用寄存器,但这无疑是一种可接受的风格,特别是在使用 ARM ABI 的 C 库代码中。

如前所述,处理一次 16 字节数据的代码(如该版本的 strlen()所做的)必须解决两个问题:将数据对齐到 16 字节边界,并防止数据访问超出包含字符串的 MMU 页的末尾。此代码通过使用 bic 指令❷将字符串指针的低 4 位设置为 0 来实现这两个要求。将低 4 位设置为 0 会将指针对齐到字符串起始位置之前或与 16 字节边界对齐。请注意,src(X1)现在可能会指向实际字符串开始之前最多 15 个字符(这些字符可能包含一些 0 字节;该函数稍后会处理这个问题)。因为 MMU 页总是从 4,096 字节边界开始(这些边界也是 16 字节边界),所以调整指针到 16 字节边界的起始位置永远不会产生超出包含字符串起始位置的 MMU 页的内存访问。

将指针对齐到 16 字节边界的另一个优点是,你不必担心在 MMU 页末尾发生意外的非法内存访问。由于 4096 可被 16 整除,在 16 字节边界上一次加载 16 字节永远不会导致跨越页面边界的内存访问。如果终止 0 字节位于这 16 字节中的任何位置,从 16 字节块的末尾读取数据是安全的。因此,通过清除指针的 LO 4 位,允许在 MMU 页内进行安全的内存访问。

对 src 指针进行 16 字节对齐的问题在于,进行对齐可能会导致指针指向字符串开始 之前 的内存。虽然这不会引起 MMU 页故障问题,但它可能导致代码错误地计算字符串的长度。至少,你不想计算字符串开始之前的任何额外字节,也不想在这些字节中出现 0 时终止字符串长度的计算。

幸运的是,代码以巧妙的方式处理了这个问题❷。首先,ld1 指令通过从对齐的地址加载 src 中的 16 字节来启动该过程。然后,cmeq 指令在这 16 字节中定位每个 0 字节,并将 0xFF 存储到 V1(vhas_null)的相应字节中,其它地方填充 0。

shrn 指令将比较掩码位右移 4 位。偶数字节现在包含两个比较掩码,分别位于 LO 和 HO 的 nibbles 中,指令的“缩小”部分提取这些偶数字节,并将它们打包到 V2 的 LO 8 字节中(总共 16 个 nibbles),然后 fmov 指令将其复制到 X2 中。

lsl 指令(在上一段中我跳过了讨论)是处理在字符串之前的 16 字节块中出现的额外字节的代码的一部分。它将原始地址乘以 4;这将是 X2 中 nibbles 的索引,实际的字符串将从那里开始(只使用移位值的 LO 6 位)。lsr 指令将 nibble 掩码右移,移动的位数是 X4(shift)的 LO 6 位中的值。这会将 X2 中位于字符串开始之前的字节的 cmeq nibble 掩码移除。这些 lsl 和 lsr 指令允许算法忽略字符串之前 16 字节块中的(可能存在的)额外字节。

最终在 X2 中的值将包含 0b1111 每当 V0(vdata)中字符串的部分出现 0 字节时,所有的其他字节则是 0b0000。特别地,如果 V0 中的字符串没有 0 字节,X2 将包含 0。如果 X2 中包含 0(意味着没有 0 字节),则字符串的终止 0 字节必须出现在字符串的后面;在这种情况下,cbz 指令将控制转移到标签 zloop。

如果 X2 不包含 0,则 0b1111 nibble 表示字符串中 0 字节的位置。rbit(反转位)指令❸会反转 X2 中的所有位,而 clz 指令会统计前导 0 的数量。由于字符串中的每个字节在 X2 中由 4 位标记,X0(结果)中的计数值是字符串长度的四倍。lsr 指令将该计数值右移 2 位,相当于将位计数除以 4,从而得到字符串的长度。

然后,函数将该长度返回给调用者,保存在 X0 中。此代码处理字符串出现在加载到 V0(vdata)的第一块字节中的情况。如果字符串足够长,以至于代码必须从内存中获取另一块 16 字节的数据,函数会将控制转移到代码❹。zloop 中的代码负责处理包含 16 个字符的块,其中从内存中读取的第一个字节是字符串的一部分(与之前的代码不同,后者的前 1–15 字节可能不是字符串的一部分)。这个循环快速扫描 16 字节的块,寻找第一个包含 0 的块。由于这个循环处理 16 字节需要五条指令(而原始的逐字节字符串长度计算例子只需要三条指令),你可以预期它会比单字节处理的代码快大约八倍。

一旦循环在 16 字节块中找到一个 0 字节❺,它就会确定该 0 字节的位置(使用与之前相同的技术❷❸),然后将从字符串起始位置到当前 16 字节块的距离加上当前 16 字节块中的非零字节数。

尽管这段代码复杂且难度较高,但计算零终止字符串的长度是一个常见的操作,因此优化是值得的。当你编写自己的代码时,几乎无法比这个算法更好。

再次强调,由于glibc(GNU C 库)的作者花费了大量时间优化他们的 ARM 字符串函数,我强烈推荐在合适的情况下调用 C 标准库函数。

14.2 面向汇编语言程序员的字符串格式

正如本书中多次提到的,如果你想编写最高效的代码,零终止字符串并不是最佳的字符串数据类型。选择一个包含长度信息的字符串格式,可能还包括其他信息,并且提供数据对齐,通常能在许多情况下提升性能。本节介绍了一种提供这些改进的示例格式。

本节讨论的字符串格式基于 HLA 字符串格式(请参见第 4.6.2 节“长度前缀字符串”,见第 188 页)。HLA 字符串由两个 32 位的长度值组成,后跟字符数据和一个零终止字节。一个字符串“变量”只是一个指向字符串第一个字符(或者如果字符串为空,指向零终止字节)的指针对象。

字符串的当前长度(字符计数,不包括零终止字节)位于地址 ptr-4,最大分配空间(字符数)值位于地址 ptr-8(其中 ptr 是指向字符数据的指针)。这两个长度值都是无符号 32 位值,支持最大长度为 4GB 的字符串。对于 HLA,字符串对象总是以 4 字节边界对齐,并且为字符串(和 0 字节)分配的存储空间总是 4 字节的倍数。

对于 64 位 CPU 上的字符串,需要做一些调整。首先,4 字节的最大长度和当前长度字段可以保留。你可能不需要能够容纳超过四十亿个字符的字符串。对齐应该在 16 字节边界上,以便使用 Neon 向量寄存器高效地一次处理 16 字节数据。最后,为字符串分配的存储空间应该始终是 16 字节的倍数(以防止读取超出字符串末尾的字节时出现问题)。以下是定义该字符串类型的结构的第一次尝试:

struct  string, -8
word    string.maxlen
word    string.len
byte    string.chars  // Note: up to 4GB chars
ends    string

使用此结构声明并且指向字符串对象的指针位于 X0,你可以按如下方式访问字符串的字段:

ldr  w1, [x0, #string.maxlen]  // Fetch the maxlen field.
ldr  w2, [x0, #string.len]     // Fetch the current length.

请注意,X0 直接指向字符串的第一个字符(如果它不是空的),因此你可以通过直接使用[x0]来引用字符数据(你不需要使用 string.chars 字段名,反正它的值为 0)。

如果你实际想为字符串数据分配存储空间,或使用字符数据初始化一些字符串存储,以下两个宏非常有用(作为初步的近似,稍后会在下一节进行小的修改):

 .macro  str.buf strName, maxSize
            .align  4   // Align on 16-byte boundary.
            .word   \maxSize
            .word   0
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

            // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
 .byte   0   // Zero-terminating byte

            // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

str.buf 宏将为一个字符串分配存储空间,该字符串最多可以容纳 maxSize 个字符(加一个零终止字节)。.align 指令确保对象从 16 字节边界开始(2⁴)。它将结构的第一个字(string.maxlen)初始化为传递的 maxSize 参数。它通过将结构的第二个 4 字节(string.len 字段)初始化为 0 来创建一个空字符串。最后,它为 maxSize + 1 个字符(包括字符串数据和一个零终止字节,且初始化为零)分配足够的存储空间,并额外分配存储空间以确保整个数据结构(包括 string.maxlen 和 string.len 字段)占用 16 字节的倍数。

这是使用 str.buf 宏的一个示例:

str.buf  myString, 100  // Maximum of 100 chars in string

str.literal 宏也会创建字符串缓冲区,但它会初始化为空字符串,而你可以在宏中指定一个字符串字面值:

str.literal hwStr, "Hello, World!\n"

请注意,str.literal 会使用你提供的字符串字面值的实际大小初始化 string.maxlen 和 string.len 字段。

这个字符串数据类型有一个小问题。虽然整个结构在 16 字节边界上对齐——并且整个结构长度是 16 字节的倍数,至少当你通过 str.buf 和 str.literal 宏创建缓冲区时——但是字符串数据的第一个字符实际上位于一个不是 16 的倍数的地址(尽管它是 8 的倍数)。为了每次处理 16 字节的字符串数据,你必须特别处理前 8 个字节,或者在结构的开头再添加 8 个字节(一些附加字段或只是 8 个填充字节)。在下一节中,你将看到这两个宏的修改,增加了一个额外的字段来解决这个问题。

14.2.1 动态字符串分配

只要你仅使用 str.buf 和 str.literal 宏为字符串变量分配存储,你就不需要担心对齐和 MMU 页面问题;你的字符串数据结构将始终在 16 字节边界上分配(因为.align 4 语句)并且始终是 16 字节的倍数长度。然而,如果你想为字符串动态分配存储(例如使用 C 标准库的 malloc()函数),你必须自己处理数据对齐和填充问题。

C 标准库的 malloc()函数对于它分配的存储没有做任何承诺,除了如果函数成功,它将返回一个指向至少你请求的存储量的指针。特别地,C 标准库并不保证你请求的存储会有特定的对齐方式。此外,malloc()可能会分配比你请求的多一些字节,但你不能依赖这一点。如果你希望存储按某个字节边界(例如 16 字节边界)进行分配,你必须自己处理。

如果你无法保证 malloc()返回一个正确对齐的内存块,你可以创建一个 str.alloc 函数来为你处理:

在入口处,将请求的存储大小加 16,以便为任何需要的填充字节留出空间。

调用 malloc()函数并传递新的分配大小。

保存 malloc()返回的指针(你稍后将需要它来释放存储)。

将指针加 15,并清除和总和的低 4 位;然后加 16,使指针包含字符串中第一个字符位置的地址。

根据需要设置 maxlen 字段。

将 len 字段初始化为 0。

在第一个字符位置存储一个零终止字节(以创建一个空字符串)。

返回指向第一个字符位置的指针作为 str.malloc 的结果。

一个释放字符串存储的函数要简单得多:你需要做的就是获取分配的指针(在 str.alloc 调用期间保存),然后调用 C 标准库的 free()函数来释放存储。你从哪里获取已分配的指针值?最好将其保存在字符串对象的数据结构中,以下是字符串结构的修改方式:

struct  string, -16
dword   string.allocPtr // At offset -16
word    string.maxlen   // At offset -8
word    string.len      // At offset -4
byte    string.chars    // At offset 0

// Note: characters in string occupy offsets
// 0 ... in this structure.

ends    string

现在,头字段占用了 16 个字节,因此 string.chars 字段将开始于 16 字节对齐的边界上(假设整个结构体都在 16 字节对齐的边界上)。

在提供实现 str.alloc 和 str.free 的代码之前,我将介绍另一个 str.alloc 将使用的有用字符串构造函数:str.bufInit。它的目的类似于 str.buf 宏,都是用于初始化一个内存缓冲区来保存字符串对象,但当你在汇编期间使用 str.buf 来声明一个静态对象时,str.bufInit 允许你在运行时初始化一块内存。str.bufInit 函数的作用如下:

  • 调整传入的指针,以确保指针中存储的地址是 16 字节对齐的(如果指针的值没有 16 字节对齐,则向指针的值添加 0 到 15)。

  • 将 string.allocPtr 字段初始化为 0(NULL),以区分由 str.alloc 创建的缓冲区。

  • 根据传入函数的缓冲区大小计算 string.maxlen 字段的值,减去为了实现 16 字节对齐所需的填充字节,以及头字段所需的 16 字节和确保整个结构体长度为 16 字节倍数所需的额外字节。

  • 将 string.len 字段初始化为 0,并在字符缓冲区区域的开始处存储一个零终止字节。

在展示这些字符串函数的实现之前,需要稍作旁白,介绍代码将使用的 volatile_save 结构体,以便保存寄存器。这个结构体出现在aoaa.inc头文件中,形式如下:

// Structure to hold the volatile registers saved by functions
// that call C stdlib funcs
//
// Note: size of this structure must be a multiple of 16 bytes!

            struct  volatile_save
            qword   volatile_save.x0x1
 qword   volatile_save.x2x3
            qword   volatile_save.x4x5
            qword   volatile_save.x6x7
            qword   volatile_save.x8x9
            qword   volatile_save.x10x11
            qword   volatile_save.x12x13
            qword   volatile_save.x14x15
            qword   volatile_save.v0
            qword   volatile_save.v1
            qword   volatile_save.v2
            qword   volatile_save.v3
            qword   volatile_save.v4
            qword   volatile_save.v5
            qword   volatile_save.v6
            qword   volatile_save.v7
            qword   volatile_save.v8
            qword   volatile_save.v9
            qword   volatile_save.v10
            qword   volatile_save.v11
            qword   volatile_save.v12
            qword   volatile_save.v13
            qword   volatile_save.v14
            qword   volatile_save.v15
            ends    volatile_save

列表 14-1 包含了 str.alloc 和 str.free 函数,以及对 str.buf 和 str.literal 宏的更新(用于处理 string.allocPtr 字段)。该列表使用了我在本节前面给出的字符串结构,其中包括 string.allocPtr 字段。对于动态分配存储的字符串,此字段将包含分配指针,str.free 将在释放字符串存储时使用该指针。对于未在堆上创建的字符串对象,此字段将包含 NULL(0)。这个结构体是列表中第一个出现的重要代码部分。

// Listing14-1.S
//
// String initialization, allocation, and deallocation functions and macros

            #include    "aoaa.inc"

// Assembly language string data structure:

            struct  string, -16
            dword   string.allocPtr // At offset -16
            word    string.maxlen   // At offset -8
            word    string.len      // At offset -4
            byte    string.chars    // At offset 0

            // Note: characters in string occupy offsets
            // 0 ... in this structure.

            ends    string

str.buf 和 str.literal 宏包含一些小的修改(与本章前面给出的同名宏),这些修改包括为 allocPtr 字段分配存储空间:

// Listing14-1.S (cont.)
//
// str.buf
//
// Allocate storage for an empty string
// with the specified maximum size:

            .macro  str.buf strName, maxSize
            .align  4         // Align on 16-byte boundary.
            .dword  0         // NULL ptr for allocation ptr
            .word   \maxSize  // Maximum string size
            .word   0         // Current string length
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

// str.literal
//
// Allocate storage for a string buffer and initialize
// it with a string literal:

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

            // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
            .byte   0   // Zero-terminating byte

            // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

请注意,这两个宏都会将此字段初始化为 NULL(0)。

列表的下一部分是代码部分,从常见的 getTitle 函数开始:

// Listing14-1.S (cont.)

            .code
            .global malloc
            .global free

ttlStr:     wastr  "Listing14-1"

// Standard getTitle function
// Returns pointer to program name in X0

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

接下来,str.bufInit 函数初始化一个内存缓冲区,用作字符串变量:

// Listing14-1.S (cont.)
//
// str.bufInit
//
// Initializes a raw memory buffer for use as an assembly
// language string object
//
// On entry:
//
//  X0- Pointer to the first byte of a buffer
//  W1- Buffer length
//
// On return:
//
//  X0- Pointer to string variable object
//  X1- Maximum string length
//
//  Carry flag clear if success, set if error

            proc    str.bufInit

            locals  str_bufInit_l
            dword   str_bufInit_l.saveX2
            byte    str_bufInit_l.stkSpace, 64
            endl    str_bufInit_l

            enter   str_bufInit_l.size
            str     x2, [fp, #str_bufInit_l.saveX2]

            // Clear HO 32 bits of X1:

          ❶ and     x1, x1, #0xFFFFFFFF

            // Ensure that the pointer is aligned
            // on a 16-byte boundary:

          ❷ add     x2, x0, #15
            bic     x2, x2, #0xf

            // Point X2 at the start of the
            // character data:

 ❸ add     x2, x2, #string.chars-string.allocPtr

            // Compute the new maxlen value:

          ❹ sub     x0, x2, x0
            subs    x1, x1, x0
            bmi     str.bufInit.bad

            // Force maxlen to be a multiple of 16:

          ❺ bic     x1, x1, #0xf

            // Error if maxlen is 0:

            cbz     x1, str.bufInit.bad

            // Initialize the string struct fields:

          ❻ str     xzr, [x2, #string.allocPtr] // NULL
            str     w1,  [x2, #string.maxlen]
            str     wzr, [x2, #string.len]      // Empty str
            strb    wzr, [x2, #string.chars]    // 0 byte

            mov     x0, x2      // Return str ptr in X0.

            ldr     x2, [fp, #str_bufInit_l.saveX2]
            adds    xzr, xzr, xzr     // Clear the carry flag.
            leave

// Error return (sets the carry flag):

str.bufInit.bad:
            ldr     x2, [fp, #str_bufInit_l.saveX2]
            cmp     x2, #0  // Set the carry flag.
            leave
            endp    str.bufInit

该函数期望 X0 中传入一个指向缓冲区的指针,以及 W1 中的缓冲区长度。它初始化字符串对象的字段,并返回指向字符串对象的指针在 X0 中。代码首先清除 X1 的高 32 位,以便代码可以处理 64 位值❶。然后,它调整 X0 中传入的指针,使其对齐到 16 字节,通过加上 16 并清除和的低 4 位❷。如果该指针尚未对齐到 16 字节地址,这将使 X0 指向下一个较高的 16 字节对齐地址。

接下来,代码调整指针,使其包含字符串中第一个字节字符数据的地址(这样其他字段相对于该指针的偏移量为负)❸。然后,代码通过减去填充字节(用于 16 字节对齐)和字符数据之前字段的大小来计算新的 maxlen 值❹。如果这个差值为负数,函数会返回错误。

代码通过清除长度值的低 4 位❺,确保字符数据的长度是 16 字节的倍数(可能会进一步减小 maxlen 的大小)。如果 maxlen 值为 0,函数将返回错误。

最后,代码初始化字符串对象的字段(生成一个空字符串)❻。请注意

adds    xzr, xzr, xzr

清除进位标志(成功返回),因为将 0 加到任何值上永远不会产生无符号溢出(进位)。还要注意

cmp     x2, #0

总是设置进位标志,因为在比较之后如果左值大于或等于(大于或相等于)右值,进位标志将被设置。当然,对于无符号值,任何值总是大于或等于 0。

在示例 14-1 中的两个函数,str.alloc 和 str.free,将调用 C 标准库中的 malloc()和 free()函数。str.alloc 和 str.free 函数保存它们修改的所有寄存器(这些寄存器不包含显式的返回值)。然而,由于 malloc()和 free()函数遵循 ARM ABI,它们被允许覆盖 volatile 寄存器集中的值。为了保存寄存器值,str.alloc 和 str.free 函数必须通过使用 volatile_save 结构来保存 volatile 寄存器。

接下来是 str.alloc 函数:

// Listing14-1.S (cont.)
//
// str.alloc
//
// Allocates storage for an assembly language string
// object on the heap (C stdlib malloc heap)
//
// On entry:
//
//  W0- Maximum string length for string object
//
// On exit:
//
//  X0- Pointer to string object (NULL if error)
//
//  Carry clear if successful, set if error

            proc    str.alloc

            locals  str_alloc
            dword   str_alloc.maxlen    // Really only a word
            dword   str_alloc.saveX1
            salign  4   // 16-byte align vsave
          ❶ byte    str_alloc.vsave, volatile_save.size
            byte    str_alloc.stkSpace, 64
            endl    str_alloc

 enter   str_alloc.size

            // Preserve X1 and point it at the
            // volatile_save.x0x1 entry in str_alloc.vsave:

            str     x1, [fp, #str_alloc.saveX1]

            // Load X1 with the effective address of
            // str_alloc.vsave (which will be the
            // volatile_save.x0x1 element):

          ❷ add     x1, fp, #str_alloc.vsave

            // Preserve all the volatile registers (call to
            // malloc may change these). Note that X1 is
            // currently pointing at volatile_save.x0x1 in
            // str_alloc.vsave (volatile_save). You don't know
            // that you *have* to save all the registers (it's
            // unlikely malloc will modify them all), but just
            // to be safe ...

            // The following code stores away X2, ..., X15 and
            // V0..V15 in successive memory locations in the
            // volatile_save structure. X1 was already preserved,
            // and it returns the result in X0.

          ❸ stp     x2,  x3,  [x1, #16]!
            stp     x4,  x5,  [x1, #16]!
            stp     x6,  x7,  [x1, #16]!
            stp     x8,  x9,  [x1, #16]!
            stp     x10, x11, [x1, #16]!
            stp     x12, x13, [x1, #16]!
            stp     x14, x15, [x1, #16]!

            str     q0,  [x1, #16]!
            str     q1,  [x1, #16]!
            str     q2,  [x1, #16]!
            str     q3,  [x1, #16]!
            str     q4,  [x1, #16]!
            str     q5,  [x1, #16]!
            str     q6,  [x1, #16]!
            str     q7,  [x1, #16]!
            str     q8,  [x1, #16]!
            str     q9,  [x1, #16]!
            str     q10, [x1, #16]!
            str     q11, [x1, #16]!
            str     q12, [x1, #16]!
            str     q13, [x1, #16]!
            str     q14, [x1, #16]!
            str     q15, [x1, #16]!

            // Save maxlen value for now:

            str     w0, [fp, #str_alloc.maxlen]

 // Force maxlen to be a multiple of 16 and
            // add in 16 extra bytes so you can ensure
            // that the storage is 16-byte aligned.
            // Also add in the size of the string.struct
            // fields:

          ❹ add     x0, x0, #31 + (string.chars-string.allocPtr)
            and     x0, x0, #0xffffffff // Fix at 32 bits.
            bic     x0, x0, #0xf        // Force to multiple of 16.

            // Call C stdlib malloc function to allocate the
            // storage:

          ❺ bl      malloc
            cmp     x0, x0              // Set carry flag on error.
            cbz     x0, str.alloc.bad   // Error if NULL return.

            mov     x1, x0              // Save allocation pointer.

            // Adjust pointer to point at start of characters
            // in string struct and 16-byte align the pointer:

          ❻ add     x0, x0, #15+(string.chars-string.allocPtr)
            bic     x0, x0, #0xf

            // Initialize the string struct fields:

            str     x1,  [x0, #string.allocPtr] // Save alloc ptr.
            ldr     w2,  [fp, #str_alloc.maxlen]
            str     w2,  [x0, #string.maxlen]   // Save maxlen.
            str     wzr, [x0, #string.len]      // Empty string.
            strb    wzr, [x0, #string.chars]    // Zero terminator

            // Restore all the volatile general-
            // purpose registers:

            adds    xzr, xzr, xzr   // Clear carry for success.

str.alloc.bad:

            // Restore all the volatile registers.
            // From this point forward, the code must
            // not change the carry flag.

          ❼ add     x1, fp, #str_alloc.vsave
            ldp     x2,  x3,  [x1, #16]!
            ldp     x4,  x5,  [x1, #16]!
            ldp     x6,  x7,  [x1, #16]!
            ldp     x8,  x9,  [x1, #16]!
            ldp     x10, x11, [x1, #16]!
            ldp     x12, x13, [x1, #16]!
            ldp     x14, x15, [x1, #16]!

 ldr     q0,  [x1, #16]!
            ldr     q1,  [x1, #16]!
            ldr     q2,  [x1, #16]!
            ldr     q3,  [x1, #16]!
            ldr     q4,  [x1, #16]!
            ldr     q5,  [x1, #16]!
            ldr     q6,  [x1, #16]!
            ldr     q7,  [x1, #16]!
            ldr     q8,  [x1, #16]!
            ldr     q9,  [x1, #16]!
            ldr     q10, [x1, #16]!
            ldr     q11, [x1, #16]!
            ldr     q12, [x1, #16]!
            ldr     q13, [x1, #16]!
            ldr     q14, [x1, #16]!
            ldr     q15, [x1, #16]!

            ldr     x1, [fp, #str_alloc.saveX1]

            leave
            endp    str.alloc

本地变量声明 str_alloc.vsave(类型为 volatile_save ❶)将保存 volatile 寄存器的值。不幸的是,这个结构非常大,无法直接使用[FP, #offset]寻址模式访问字段。因此,代码将 volatile_save.x0x1 字段的地址计算到 X1 中,并将连续的寄存器存储到 X1 指向的块中❷。这段代码必须在将任何内容存储到 str_alloc.vsave 之前先初始化 X1,因此它首先在另一个本地变量中保存 X1。由于函数返回结果在 X0 中并且必须将 X1 保存在不同的位置,因此这段代码实际上并没有使用 str_alloc.vsave 的 volatile_save.x0x1 字段。

代码保存了除 X0 和 X1 ❸ 之外的所有易变寄存器。它使用预增寻址模式,因此在将 X2 和 X3 寄存器写入结构时,会跳过 volatile_save.x0x1 字段。

接下来,代码通过将 16 加到 maxlen 来计算字符串分配的大小(以覆盖字符串数据结构中的额外字段)❹;它还调整分配的大小,使其成为 16 的倍数(大于或等于请求的大小加 16)。这确保字符数据区域的长度是 16 字节的倍数,因此字符串处理代码可以一次操作 16 字节,而不必担心访问超出分配存储的区域。

对 malloc() ❺ 的调用为字符串对象分配存储空间。该代码检查返回结果是否为 NULL(0),如果 malloc() 失败,则返回错误。成功时,代码初始化字符串对象的字段,然后返回指向该对象的指针到 X0(在成功调用时,进位标志清除)❻。最后,代码恢复所有易变寄存器(X0 除外,因为它包含了函数结果)❼。

接下来,代码包括了 str.free 函数:

// Listing14-1.S (cont.)
//
// str.free
//
// Deallocates storage for an assembly language string
// object that was previously allocated via str.alloc
//
// On entry:
//
//  W0- Pointer to string object to deallocate

            proc    str.free

            locals  str_free
            dword   str_free.maxlen // Really a word
            dword   str_free.saveX1
            salign  4   // 16-byte align vsave
            byte    str_free.vsave, volatile_save.size
            byte    str_free.stkSpace,64
            endl    str_free

            enter   str_free.size

            // Preserve X1:

            str     x1, [fp, #str_free.saveX1]

            // Load X1 with the effective address of
            // str_alloc.vsave (which will be the
            // volatile_save.x0x1 element):

            add     x1, fp, #str_free.vsave

            // Preserve all the volatile registers (call to free
            // may change these):

          ❶ stp     x2,  x3,  [x1, #16]!
            stp     x4,  x5,  [x1, #16]!
            stp     x6,  x7,  [x1, #16]!
            stp     x8,  x9,  [x1, #16]!
            stp     x10, x11, [x1, #16]!
            stp     x12, x13, [x1, #16]!
            stp     x14, x15, [x1, #16]!

            str     q0,  [x1, #16]!
            str     q1,  [x1, #16]!
            str     q2,  [x1, #16]!
            str     q3,  [x1, #16]!
            str     q4,  [x1, #16]!
            str     q5,  [x1, #16]!
            str     q6,  [x1, #16]!
            str     q7,  [x1, #16]!
 str     q8,  [x1, #16]!
            str     q9,  [x1, #16]!
            str     q10, [x1, #16]!
            str     q11, [x1, #16]!
            str     q12, [x1, #16]!
            str     q13, [x1, #16]!
            str     q14, [x1, #16]!
            str     q15, [x1, #16]!

            // Fetch the allocation pointer from the
            // string struct data type:

          ❷ ldr     x1, [x0, #string.allocPtr]

            // Make sure it's not NULL (non-allocated
            // pointer):

          ❸ cbz     x1, str.free.done

            // Defensive code, set the allocPtr field to
            // NULL:

            str     xzr, [x0, #string.allocPtr]

            // Deallocate the storage:

          ❹ mov     x0, x1
            bl      free

str.free.done:

            // Restore the volatile register before
            // returning:

            add     x1, fp, #str_free.vsave
          ❺ ldp     x2,  x3,  [x1, #16]!
            ldp     x4,  x5,  [x1, #16]!
            ldp     x6,  x7,  [x1, #16]!
            ldp     x8,  x9,  [x1, #16]!
            ldp     x10, x11, [x1, #16]!
            ldp     x12, x13, [x1, #16]!
            ldp     x14, x15, [x1, #16]!

            ldr     q0,  [x1, #16]!
            ldr     q1,  [x1, #16]!
            ldr     q2,  [x1, #16]!
            ldr     q3,  [x1, #16]!
            ldr     q4,  [x1, #16]!
            ldr     q5,  [x1, #16]!
            ldr     q6,  [x1, #16]!
            ldr     q7,  [x1, #16]!
            ldr     q8,  [x1, #16]!
            ldr     q9,  [x1, #16]!
            ldr     q10, [x1, #16]!
 ldr     q11, [x1, #16]!
            ldr     q12, [x1, #16]!
            ldr     q13, [x1, #16]!
            ldr     q14, [x1, #16]!
            ldr     q15, [x1, #16]!

            ldr     x1, [fp, #str_free.saveX1]
            leave
            endp    str.free

str.free 函数还调用了 C 标准库函数,因此必须保留所有易变寄存器。实际上,保留寄存器的代码 ❶ ❺ 占据了该函数的大部分语句。

调用者将汇编字符串对象的地址传递到 X0 寄存器中传递给此函数。然而,这不是代码传递给 C 标准库 free() 函数的地址;相反,代码获取的是在 string.allocPtr 字段中找到的地址,并将其传递给 free() ❷。

在实际调用 free() 之前,代码首先检查该指针值是否为 NULL ❸。一个 NULL 的 string.allocPtr 值意味着字符串最初并没有通过调用 str.alloc 分配内存。如果是这种情况,str.free 直接返回(不注册错误),允许代码在动态和静态分配的对象上调用此函数。当一个任意的字符串指针被传递到一个不知道原始存储方式的函数时,这有时很方便。

最后,str.free 函数调用 free() 函数 ❹ 来将存储空间归还给堆。

这是一个主程序示例(以及一些数据),用来测试清单 14-1 中出现的函数:

// Listing14-1.S (cont.)
//
// Some read-only strings:

fmtStr:     wastr   "hwStr=%s"
fmtStr2:    wastr   "hwDynamic=%s"
fmtStr3:    wastr   "strBufInit error\n"

            str.literal hwLiteral, "Hello, world!\n"

///////////////////////////////////////////////////////////
//
// Main program to test the code:

            proc    asmMain, public

            locals  lcl
            qword   hwStr
            qword   hwDynamic
            byte    hwBuffer, 256
            byte    stkSpace, 64
            endl    lcl

 enter   lcl.size      // Reserve space for locals.

            // Demonstrate call to str.bufInit:

            // Initialize hwBuffer as a string object and
            // save pointer in hwStr:

            add     x0, fp, #hwBuffer
            mov     x1, #256    // Buffer size
            bl      str.bufInit
            str     x0, [fp, #hwStr]

            // Force copy of hwLiteral into hwStr:

            lea     x2, hwLiteral
            ldr     w3, [x2, #string.len]   // Get length.
            str     w3, [x0, #string.len]   // Save hwStr len.

            // Cheesy string copy. You know the length is less
            // than 16 bytes and both string objects have a
            // minimum of 16 character locations available.

            ldr     q0, [x2]    // Copy "Hello, world!\n" string.
            str     q0, [x0]

            // Now, hwStr contains a copy of hwLiteral.
            // Print hwStr (because the assembly language
            // string format always includes a zero-terminating
            // byte, you can just call printf to print the string).
            // Note that X0 still contains the hwStr pointer.

            mov     x1, x0
            lea     x0, fmtStr
            mstr    x1, [sp]
            bl      printf

            // Demonstrate call to str.alloc and str.free:

            mov     x0, #256    // String size
            bl      str.alloc
            bcs     badAlloc
            str     x0, [fp, #hwDynamic]

            // Force copy of hwLiteral into hwDynamic:

            lea     x2, hwLiteral
            ldr     w3, [x2, #string.len]   // Get length.
            str     w3, [x0, #string.len]   // Save hwDynamic len.

            // Cheesy string copy. You know the length is less
            // than 16 bytes and both string objects have a
            // minimum of 16 character locations available.

 ldr     q0, [x2]    // Copy "Hello, world!\n" string.
            str     q0, [x0]

            // Now hwDynamic contains a copy of hwLiteral.
            // Print hwDynamic (because the assembly language
            // string format always includes a zero-terminating
            // byte, you can just call printf to print the string).
            // Note that X0 still contains the hwDynamic pointer.

            mov     x1, x0
            lea     x0, fmtStr2
            mstr    x1, [sp]
            bl      printf

            // Free the string storage:

            ldr     x0, [fp, #hwDynamic]
            bl      str.free

AllDone:    leave

badAlloc:   lea     x0, fmtStr3
            bl      printf
            leave
            endp    asmMain

asmMain 函数提供了几个简单的示例,演示了如何调用 str.alloc、str.free 和 str.bufInit 函数。

这是清单 14-1 的构建命令和示例程序输出:

% ./build Listing14-1
% ./Listing14-1
Calling Listing14-1:
hwStr=Hello, world!
hwDynamic=Hello, world!
Listing14-1 terminated

如你所见,这段代码正确地将静态字符串复制到了动态分配的字符串中。

14.2.2 字符串复制函数

清单 14-1 演示了可能最重要的字符串函数的缺失:一个将字符数据从一个字符串复制到另一个字符串的函数。本节介绍了 str.cpy,这是第二常用的字符串函数(在我看来,仅次于字符串长度函数),它将一个字符串变量中的数据复制并存储到另一个字符串变量中。

str.cpy 函数必须执行以下操作:

  • 比较源字符串的长度与目标字符串的最大长度,如果源字符串无法容纳到目标字符串变量中,则返回错误。

  • 将 len 字段从源字符串复制到目标字符串。

  • 从源字符串复制 len + 1 个字符到目标字符串,这也会复制零终止字节。

列表 14-2 提供了该函数的实现。

// Listing14-2.S
//
// A str.cpy string copy function

            #include    "aoaa.inc"

// Assembly language string data structure:

            struct  string, -16
            dword   string.allocPtr // At offset -16
            word    string.maxlen   // At offset -8
            word    string.len      // At offset -4
            byte    string.chars    // At offset 0

            // Note: characters in string occupy offsets
            // 0 ... in this structure

            ends    string

// str.buf
//
// Allocate storage for an empty string
// with the specified maximum size:

            .macro  str.buf strName, maxSize
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   \maxSize
            .word   0
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

// str.literal:
//
// Allocate storage for a string buffer and initialize
// it with a string literal:

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

 // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
            .byte   0   // Zero-terminating byte

            // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

///////////////////////////////////////////////////////////

            .data
            str.buf     destination, 256
            str.literal source, "String to copy"

///////////////////////////////////////////////////////////

            .code
            .global malloc
            .global free

ttlStr:     wastr  "Listing14-2"

// Standard getTitle function
// Returns pointer to program name in X0

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

///////////////////////////////////////////////////////////
//
// str.cpy
//
// Copies the data from one string variable to another.
//
// On entry:
//
//  X0- Pointer to source string (string struct variable)
//  X1- Pointer to destination string
//
// On exit:
//
//  Carry flag clear if no errors; carry is set if
//  the source string will not fit in the destination.

            proc    str.cpy

            locals  str_cpy
            qword   str_cpy.saveV0
 qword   str_cpy.saveX2X3
            dword   str_cpy.saveX4
            byte    str_cpy.stkSpace,64 // Not actually needed
            endl    str_cpy

            enter   str_cpy.size

            // Preserve X2 ... X4 and V0:

            str     q0,     [fp, #str_cpy.saveV0]
            stp     x2, x3, [fp, #str_cpy.saveX2X3]
            str     x4,     [fp, #str_cpy.saveX4]

            // Ensure the source will fit in the destination
            // string object:

          ❶ ldr     w4, [x0, #string.len]
            ldr     w3, [x1, #string.maxlen]
            cmp     w4, w3
            bhi     str.cpy.done    // Note: carry is set.

            // Set the length of the destination string
            // to the length of the source string:

          ❷ str     w4, [x1, #string.len]

            // X4 contains the number of characters to copy.
            // While this is greater than 16, copy 16 bytes
            // at a time from source to dest:

          ❸ mov     x2, x0  // Preserve X0 and X1.
            mov     x3, x1
cpy16:      ldr     q0, [x2], #16
            str     q0, [x3], #16
            subs    w4, w4, #16
            bhi     cpy16

// At this point, you have fewer than 16 bytes to copy. If
// W4 is not 0, just copy 16 remaining bytes (you know,
// because of the string data structure, that if you have at
// least 1 byte left to copy, you can safely copy
// 16 bytes):

          ❹ beq     setZByte    // Skip if 0 bytes.

            ldr     q0, [x2]
            str     q0, [x3]

// Need to add a zero-terminating byte to the end of
// the string. Note that maxlen does not include the
// 0 byte, so it's always safe to append the 0
// byte to the end of the string.

setZByte:   ldr     w4,  [x0, #string.len]
          ❺ strb    wzr, [x1, w4, uxtw]

            adds    wzr, wzr, wzr   // Clears the carry

str.cpy.done:
            ldr     q0,     [fp, #str_cpy.saveV0]
            ldp     x2, x3, [fp, #str_cpy.saveX2X3]
            ldr     x4,     [fp, #str_cpy.saveX4]
            leave
            endp    str.cpy

///////////////////////////////////////////////////////////
//
// A read-only format string:

fmtStr:     wastr   "source='%s', destination='%s'\n"

///////////////////////////////////////////////////////////
//
// Main program to test the code:

            proc    asmMain, public

            locals  lcl
            byte    stkSpace, 64
            endl    lcl

            enter   lcl.size      // Reserve space for locals.

            lea     x0, source
            lea     x1, destination
            bl      str.cpy

            mov     x2, x1
            mov     x1, x0
            lea     x0, fmtStr
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

AllDone:    leave
            endp    asmMain

str.cpy 函数直截了当且高效,几乎完全得益于字符串数据类型的设计(特别是字符串的对齐和填充要求)。代码首先检查以确保源字符串的当前长度小于或等于目标字符串允许的最大长度❶。如果源字符串的长度过大,控制权将转移到函数的末尾,并返回。该比较会在源长度“大于或等于”目标最大长度时设置进位标志。因此,如果字符串.len 字段大于字符串.maxlen 字段,比较自动设置进位标志以指示字符串溢出错误。这是因为新的目标字符串将是源字符串的副本,因此代码接着将目标字符串.len 字段设置为源字符串的长度❷。

代码负责将字符数据从源字符串复制到目标字符串❸。这是一个重复...直到循环,因此它总是复制 16 个字节,即使字符串长度为 0 也是如此。没关系,因为字符串数据类型始终确保字符存储区是 16 字节的倍数(包括零终止字节的空间)。这个循环可能最终只会复制零终止字节和 15 个垃圾数据字节,但它不会访问超出字符串对象存储区域之外的内存。

对于每 16 个字节,循环会复制❸,代码会将长度计数器(W4)减少 16。subs 指令的作用与 cmp 指令完全相同,因此 bhi 指令会在 W4 中的值大于 16 时重复执行循环(在 subs 指令之前)。如果字符串的长度是 16 字节的倍数,这个循环会在复制完最后 16 个字节后终止(当 W4 递减到 0 时)。在这种情况下,beq 指令❹会将控制权转移到代码中,以附加零终止字节。

如果字符串的长度不是 16 的整数倍,减去 16 后会得到一个大于 0 但小于 16 的结果(意味着还有一些字符需要从源字符串复制到目标字符串)。因此,代码会继续执行到 ldr/str 指令,并复制字符串中剩余的字节(加上一些垃圾字节)。

最后,代码将把零终止字节存储到字符串的末尾❺,以防前面的 ldr/str 指令没有将该字节与字符数据一起复制过来。

注意

从技术上讲, beq 指令在清单 14-2 中是多余的。如果字符串的长度恰好是 16 字节的倍数,至少必须复制 1 个额外的字节:零终止字节。因此,数据结构保证包含至少 16 个附加字节,因此跳过到下一个加载和存储指令对程序不会造成问题。作为一个有趣的实验,你可以确定移除 beq 指令是否会改善或损害算法的性能。

下面是清单 14-2 中代码的构建命令和示例程序输出:

% ./build Listing14-2
% ./Listing14-2
Calling Listing14-2:
source='String to copy', destination='String to copy'
Listing14-2 terminated

尽管这个字符串短于 16 个字符,并没有完全测试 str.cpy,但我已经用不同的源字符串运行了这个程序,以验证它是否适用于更长的字符串。

14.2.3 字符串比较函数

在复制字符串之后,比较字符串是你可能最常用的字符串函数。要比较两个字符字符串,请使用以下步骤:

1.  从两个字符串的对应索引提取一个字符。

2.  比较这两个字符。如果它们不相等,比较完成,字符串比较的结果就是该字符比较的结果(不相等、小于或大于)。如果它们相等且不为零,重复步骤 1。

3.  如果两个字符都是 0 字节,比较结束,两个字符串相等。

这个算法适用于零终止字符串(并且由于它们也是零终止的,它适用于本章中给出的汇编语言字符串格式)。请注意,比较算法不使用字符串长度值。

这里是一个简单版本的字符串比较,使用 ARM64 汇编语言,假设 X0 和 X1 指向要比较的字符串数据:

cmpLp:
    ldrb w2, [x0], #1
    ldrb w3, [x1], #1
    cmp  w2, w3
    bne  strNE
    cbnz w2, cmpLp

// At this point, the strings are equal.
    .
    .
    .
strNE:
    // At this point, the strings are not equal.

正如你在 strlen()函数中看到的,使用 64 位或 128 位寄存器一次处理多个字节通常要快得多。你能通过使用向量寄存器来提高性能吗?这样做的一个大问题是,向量比较会检查特定的比较条件(lt, le, eq, ne, gt 或 ge)。它们不会设置条件码标志,因此你可以使用条件分支,这也是大多数程序员更倾向使用的方式。既然如此,使用 64 位通用寄存器一次比较八个字符可能是最好的解决方案。

考虑到 glibc 的 strlen()函数的效率,你可能会想知道它的 strcmp()函数是否也同样高效。清单 14-3 展示了这个函数,并在注释中解释了它的操作。

// Listing14-3.S
//
// GNU glibc strcmp function
//
// Copyright (C) 2013 ARM Ltd.
// Copyright (C) 2013 Linaro.
//
// This code is based on glibc cortex strings work originally
// authored by Linaro and relicensed under GPLv2 for the
// Linux kernel. The original code can be found @
//
// http://bazaar.launchpad.net/~linaro-toolchain-dev/
// cortex-strings/trunk/
//
// files/head:/src/aarch64/
//
// This program is free software; you can redistribute it
// and/or modify it under the terms of the GNU General Public
// License version 2 as published by the Free Software
// Foundation.
//
// This program is distributed in the hope that it will be
// useful, but WITHOUT ANY WARRANTY; without even the implied
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public
// License along with this program. If not, see
// <http://www.gnu.org/licenses/>.

#include <linux/linkage.h>
#include <asm/assembler.h>

// Compare two strings
//
// Parameters:
//  X0 - Const string 1 pointer
//  X1 - Const string 2 pointer
//
// Returns:
//  X0 - An integer less than, equal to, or greater
//       than zero if S1 is found, respectively, to be
//       less than, to match, or to be greater than S2

#define REP8_01 0x0101010101010101
#define REP8_7f 0x7f7f7f7f7f7f7f7f
#define REP8_80 0x8080808080808080

// Parameters and result

src1        .req    x0
src2        .req    x1
result      .req    x0

// Internal variables

data1       .req    x2
data1w      .req    w2
data2       .req    x3
data2w      .req    w3
has_nul     .req    x4
diff        .req    x5
syndrome    .req    x6
tmp1        .req    x7
tmp2        .req    x8
tmp3        .req    x9
zeroones    .req    x10
pos         .req    x11

strcmp:
    eor tmp1, src1, src2
    mov zeroones, #REP8_01
    tst tmp1, #7
    b.ne    .Lmisaligned8
    ands    tmp1, src1, #7
    b.ne    .Lmutual_align

// NUL detection works on the principle that (X - 1) &
// (~X) & 0x80 (=> (X - 1) & ~(X | 0x7f)) is nonzero if
// a byte is 0, and can be done in parallel across the
// entire word.

.Lloop_aligned:
    ldr data1, [src1], #8
    ldr data2, [src2], #8
.Lstart_realigned:
    sub tmp1, data1, zeroones
    orr tmp2, data1, #REP8_7f
    eor diff, data1, data2  // Nonzero if differences found
    bic has_nul, tmp1, tmp2 // Nonzero if NUL terminator
    orr syndrome, diff, has_nul
    cbz syndrome, .Lloop_aligned
    b   .Lcal_cmpresult
.Lmutual_align:

// Sources are mutually aligned but are not currently at
// an alignment boundary. Round down the addresses and
// then mask off the bytes that precede the start point:

    bic src1, src1, #7
    bic src2, src2, #7
    lsl tmp1, tmp1, #3  // Bytes beyond alignment -> bits
    ldr data1, [src1], #8
    neg tmp1, tmp1      // (Bits to align) - 64
    ldr data2, [src2], #8
    mov tmp2, #~0

    lsr tmp2, tmp2, tmp1 // Shift (tmp1 & 63)
    orr data1, data1, tmp2
    orr data2, data2, tmp2
 b   .Lstart_realigned
.Lmisaligned8:

// Get the align offset length to compare per byte first.
// After this process, one string's address will be
// aligned.

    and     tmp1, src1, #7
    neg     tmp1, tmp1
    add     tmp1, tmp1, #8
    and     tmp2, src2, #7
    neg     tmp2, tmp2
    add     tmp2, tmp2, #8
    subs    tmp3, tmp1, tmp2
    csel    pos, tmp1, tmp2, hi // Choose the maximum.
.Ltinycmp:
    ldrb    data1w, [src1], #1
    ldrb    data2w, [src2], #1
    subs    pos, pos, #1
    ccmp    data1w, #1, #0, ne      // NZCV = 0b0000
    ccmp    data1w, data2w, #0, cs  // NZCV = 0b0000
    b.eq    .Ltinycmp
    cbnz    pos, 1f  // Find the null or unequal ...
    cmp     data1w, #1
    ccmp    data1w, data2w, #0, cs
    b.eq    .Lstart_align  // The last bytes are equal.
1:
    sub result, data1, data2
    ret
.Lstart_align:
    ands    xzr, src1, #7
    b.eq    .Lrecal_offset

    // Process more leading bytes to make str1 aligned:

    add src1, src1, tmp3
    add src2, src2, tmp3

    // Load 8 bytes from aligned str1 and nonaligned str2:

    ldr data1, [src1], #8
    ldr data2, [src2], #8
    sub tmp1, data1, zeroones
    orr tmp2, data1, #REP8_7f
    bic has_nul, tmp1, tmp2
    eor diff, data1, data2 // Nonzero if differences found
    orr syndrome, diff, has_nul
    cbnz    syndrome, .Lcal_cmpresult

    // How far is the current str2 from the alignment boundary?

    and tmp3, tmp3, #7
.Lrecal_offset:
    neg pos, tmp3
.Lloopcmp_proc:

// Divide the 8 bytes into two parts. First, adjust the src
// to the previous alignment boundary, load 8 bytes from
// from the SRC2 alignment boundary, then compare with the
// relative bytes from SRC1\. If all 8 bytes are equal,
// start the second part's comparison. Otherwise, finish
// the comparison. This special handle can guarantee all
// the accesses are in the thread/task space in order to
// avoid overrange access.

    ldr data1, [src1,pos]
    ldr data2, [src2,pos]
    sub tmp1, data1, zeroones
    orr tmp2, data1, #REP8_7f
    bic has_nul, tmp1, tmp2
    eor diff, data1, data2  // Nonzero if differences found
    orr syndrome, diff, has_nul
    cbnz    syndrome, .Lcal_cmpresult

    // The second part of the process:

    ldr data1, [src1], #8
    ldr data2, [src2], #8
    sub tmp1, data1, zeroones
    orr tmp2, data1, #REP8_7f
    bic has_nul, tmp1, tmp2
    eor diff, data1, data2  // Nonzero if differences found
    orr syndrome, diff, has_nul
    cbz syndrome, .Lloopcmp_proc
.Lcal_cmpresult:

// Reverse the byte order as big-endian, so CLZ can find
// the most significant 0 bits:

    rev syndrome, syndrome
    rev data1, data1
    rev data2, data2

    clz pos, syndrome

// The MS-nonzero bit of the syndrome marks either the
// first bit that is different or the top bit of the
// first 0 byte. Shifting left now will bring the
// critical information into the top bits.

    lsl data1, data1, pos
    lsl data2, data2, pos

// But you need to zero-extend (char is unsigned) the value
// and then perform a signed 32-bit subtraction:

    lsr data1, data1, #56
    sub result, data1, data2, lsr #56
    ret

大多数复杂性源于代码是为处理未对齐到 8 字节边界的字符串数据而编写的。如果可以假设源字符串和目标字符串始终对齐到 8 字节边界,这段字符串比较代码可以编写得更简单。由于汇编语言中的字符串对象在定义上始终对齐到 16 字节边界,因此可以为这些字符串编写更高效的比较函数。列表 14-4 提供了这样的 str.cmp 函数。

// Listing14-4.S
//
// A str.cmp string comparison function

            #include    "aoaa.inc"

// Assembly language string data structure:

            struct  string, -16
            dword   string.allocPtr // At offset -16
            word    string.maxlen   // At offset -8
            word    string.len      // At offset -4
            byte    string.chars    // At offset 0

            // Note: characters in string occupy offsets
            // 0 ... in this structure.

            ends    string

// str.buf
//
// Allocate storage for an empty string
// with the specified maximum size:

            .macro  str.buf strName, maxSize
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   \maxSize
            .word   0
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

// str.literal
//
// Allocate storage for a string buffer and initialize
// it with a string literal:

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

 // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
            .byte   0   // Zero-terminating byte

            // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

///////////////////////////////////////////////////////////

            .data
            str.buf     destination, 256
            str.literal left,   "some string"
            str.literal right1, "some string"
            str.literal right2, "some string."
            str.literal right3, "some string"
            str.literal right4, ""
            str.literal right5, "t"
            str.literal right6, " "

            str.literal left2,  "some string 16.."
            str.literal right7, "some string 16.."
            str.literal right8, "some string 16."
            str.literal right9, "some string 16..."

///////////////////////////////////////////////////////////

            .code
            .global malloc
            .global free

ttlStr:     wastr   "Listing14-4"

// Standard getTitle function
// Returns pointer to program name in X0

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

///////////////////////////////////////////////////////////
//
// str.cmp
//
// Compares two string objects
//
// On entry:
//
//  X0- Pointer to left string
//  X1- Pointer to right string
//
//      left op right
//
//  Where op is the string comparison operation
//
// On exit:
//
//  Condition code flags contain state of comparison

            proc    str.cmp

            locals  str_cmp
            qword   str_cmp.saveX2X3
            dword   str_cmp.saveX4X5
            dword   str_cmp.saveX6X7
            byte    str_cmp.stkSpace,64
            endl    str_cmp

            enter   str_cmp.size

            // Preserve X2 ... X7:

          ❶ stp     x2, x3, [fp, #str_cmp.saveX2X3]
            stp     x4, x5, [fp, #str_cmp.saveX4X5]
            stp     x6, x7, [fp, #str_cmp.saveX6X7]

            mov     x2, x0  // Preserve X0 and X1.
            mov     x3, x1

            // Compute the minimum of the string lengths:

          ❷ ldr     w6, [x2, #string.len]
            ldr     w7, [x3, #string.len]
            cmp     w6, w7
            csel    w6, w6, w7, hs
            b.al    cmpLen

cmp8:
          ❸ ldr     x4, [x2], #8
            ldr     x5, [x3], #8
            rev     x4, x4
            rev     x5, x5
            cmp     x4, x5
            bne     str.cmp.done
cmpLen:
          ❹ subs    w6, w6, #8      // Also compares W6 to 8
            bhs     cmp8

            // Fewer than eight characters left (and more
            // than zero). Cheapest to just compare them
            // one at a time:

          ❺ adds    w6, w6, #8
            beq     str.cmp.done  // If lens are equal

cmp1:
          ❻ ldrb    w4, [x2], #1
            ldrb    w5, [x3], #1
            cmp     w4, w5
            bne     str.cmp.done
            subs    w6, w6, #1
            bne     cmp1

            // At this point, the strings are equal
            // through the length of the shorter
            // string. The comparison is thus based
            // on the result of comparing the lengths
            // of the two strings.

cmpLens:
          ❼ ldr     w6, [x0, #string.len]   // Fetch left len.
            cmp     w6, w7                  // Right len

str.cmp.done:
            ldp     x2, x3, [fp, #str_cmp.saveX2X3]
            ldp     x4, x5, [fp, #str_cmp.saveX4X5]
            ldp     x6, x7, [fp, #str_cmp.saveX6X7]
            leave
            endp    str.cmp

///////////////////////////////////////////////////////////
//
// Some read-only strings:

ltFmtStr:   wastr   "Left ('%s') is less than right ('%s')\n"
gtFmtStr:   wastr   "Left ('%s') is greater than right ('%s')\n"
eqFmtStr:   wastr   "Left ('%s') is equal to right ('%s')\n"

///////////////////////////////////////////////////////////
//
// prtResult
//
// Utility function to print the result of a string
// comparison

          ❽ proc    prtResult

            mov     x2, x1
            mov     x1, x0
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            beq     strsEQ
            bhi     strGT

            // Must be LT at this point

            lea     x0, ltFmtStr
            b       printf

strsEQ:     lea     x0, eqFmtStr
            b       printf

strGT:      lea     x0, gtFmtStr
            b       printf

            endp    prtResult

///////////////////////////////////////////////////////////
//
// Main program to test the code:

            proc    asmMain, public

            locals  lcl
            byte    stkSpace, 64
            endl    lcl

            enter   lcl.size      // Reserve space for locals.

            lea     x0, left
            lea     x1, right1
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right2
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right3
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right4
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right5
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right6
            bl      str.cmp
            bl      prtResult

            lea     x0, left2
            lea     x1, right7
            bl      str.cmp
            bl      prtResult

 lea     x0, left2
            lea     x1, right8
            bl      str.cmp
            bl      prtResult

            lea     x0, left2
            lea     x1, right9
            bl      str.cmp
            bl      prtResult

AllDone:    leave
            endp    asmMain

str.cmp 函数不会修改 X0 或 X1 寄存器,但它会修改 X2 到 X7 寄存器,因此这段代码首先通过保存这些寄存器的值来保护这些寄存器 ❶。接着,它将 X0 和 X1 中的值分别复制到 X2 和 X3 中,后续的代码会使用这些值。

在比较字符串时,str.cmp 只会比较到较短字符串的长度。代码会计算两个字符串的最小长度 ❷,并将结果保存在 W6 中。如果两个字符串的长度等于较短字符串的长度,则较短的字符串被认为小于较长的字符串。

cmp8 循环每次比较 8 字节的字符 ❸。字符串本质上是大端数据结构,意味着字符串中的低位字节具有最高的值。因此,你不能简单地将 8 个连续的字节加载到一对 64 位寄存器中并直接比较这些寄存器;那样会产生小端比较结果。为了解决这个问题,代码在比较之前执行两条 rev 指令,将两个 64 位寄存器中的字节交换,从而实现大端比较。

在比较完两个双字后,如果这两个双字不相等,代码会跳转到返回代码的位置。此时,ARM 条件码将保存比较结果。如果两个双字相等,cmp8 循环必须重复,直到所有字符都比较完或者找到一对不相等的双字。代码从 W6 中减去 8 并重复执行,如果减法前的值大于或等于 8(记住,subs 和 cmp 会以相同方式设置标志) ❹。

因为这段代码在比较相应字符之前会从 W6 中减去 8,所以即使 W6 的值为 0,仍然会剩下八个字符进行比较。这就是为什么即使减去 8 后结果为 0,这段代码仍然会重复执行的原因。

如果代码进入到 ❺,则 W6 包含一个负数结果。代码将 8 加到这个值上,以确定它仍然需要处理的字符数。如果结果为 0,说明两个字符串长度相同,并且所有字符都相等;此时,代码退出(并且标志已经包含适当的值)。如果结果不为 0,代码会逐个字符处理这两个字符串中的剩余字符 ❻。(假设字符串长度随机,每个字符处理四条指令,对于平均每个字符串四个字符的情况,这通常比尝试清除多余字节并一次比较 8 字节要更快。)

如果代码跳转到或通过 cmpLens❼,则表示两个字符串在较短字符串的长度处相等。在此时,代码通过比较两个字符串的长度来确定比较结果。

主程序比较几个字符串来测试 str.cmp 函数。prtResult 函数❽是一个简短的实用函数,用于打印比较结果。

注意

如果保留 X0 和 X1 而不是 X2 和 X3,代码会稍微高效一点,然后使用 X0 和 X1 而不是 X2 和 X3。然而,我保留了 X0 和 X1,因为在开发过程中我使用了 printf() 来打印一些调试信息。如果两个额外的指令(那些将 X0 和 X1 移动到 X2 和 X3 的指令)让你不舒服,随时可以修改这段代码,使用 X0/X1 而不是 X2/X3。

和 glibc 的 strcmp()函数一样,str.cmp 函数期望在 X0 和 X1 中传入指向要比较的两个字符串的指针。左侧字符串是 X0,右侧字符串是 X1。左侧和右侧与它们在比较表达式中的位置相关。下面的示例演示了两个字符串在 if 语句中的位置:

if(leftStr <= rightStr) then ...

strcmp()函数返回一个结果到 X0,表示比较结果:

  • 如果 X0 为负,则左侧字符串(X0)小于右侧字符串(X1)。

  • 如果 X0 为 0,则两个字符串相等。

  • 如果 X0 为正,则左侧字符串大于右侧字符串。

另一方面,str.cmp 函数会将比较结果返回到条件代码标志中,因此你可以在返回后使用条件分支指令来测试结果。

以下是清单 14-4 的构建命令和示例输出:

% ./build Listing14-4
% ./Listing14-4
Calling Listing14-4:
Left ('some string') is equal to right ('some string')
Left ('some string') is less than right ('some string.')
Left ('some string') is greater than right ('some string')
Left ('some string') is greater than right ('')
Left ('some string') is less than right ('t')
Left ('some string') is greater than right (' ')
Left ('some string 16..') is equal to right ('some string 16..')
Left ('some string 16..') is greater than right ('some string 16.')
Left ('some string 16..') is less than right ('some string 16..')
Listing14-4 terminated

如你所见,str.cmp 返回了正确的测试字符串结果。 #### 14.2.4 子字符串函数

本章我提供的最后一个 ASCII 示例是子字符串函数 str.substr。一个典型的子字符串函数从字符串中提取一部分字符,创建一个由子字符串组成的新字符串。它通常有四个参数:指向源字符串的指针、子字符串提取起始位置的索引、指定从源字符串中复制字符的长度以及指向目标字符串的指针。

子字符串操作有几个问题:

  • 你不能假设源字符是按 16 字节边界对齐的。

  • 指定的起始索引可能超出源字符串的长度。

  • 指定的子字符串长度可能超出源字符串的末尾。

  • 子字符串的长度可能超过目标字符串的最大长度。

第一个问题通常是无法处理的。大多数情况下,源字符或目标字符的地址将是不对齐的。本节中的 str.substr 代码将选择保持目标地址在 16 字节边界上对齐(默认情况下会得到这个对齐)。在复制数据时,函数必须仔细检查长度,以确保它不会读取超过源字符串数据结构末尾的数据。

你可以通过两种方式处理第二个问题:要么返回一个错误代码而不复制任何数据,要么简单地将空字符串存储到目标中。后者通常是最方便的解决方案,我在本节的代码中依赖于它。

同样,处理第三个问题有两种方法:要么返回错误代码而不复制任何数据,要么将从起始索引到源字符串末尾的所有字符复制到目标字符串中。同样,后者通常是最方便的解决方案,str.substr 代码依赖于它。

第四个问题稍微复杂一些。str.substr代码可能会截断它复制的字符串,但这种情况通常表示应用程序发生了严重错误(字符串溢出)。因此,str.substr将在进位标志中返回一个标志,表示成功或失败。

注意

如果你更倾向于返回第二和第三个问题的错误状态,可以很容易地修改 str.substr 来实现这一点。

清单 14-5 提供了 str.substr 函数和一个测试该函数的示例主程序。

// Listing14-5.S
//
// A str.substr substring function

 #include    "aoaa.inc"

// Assembly language string data structure:

            struct  string, -16
            dword   string.allocPtr // At offset -16
            word    string.maxlen   // At offset -8
            word    string.len      // At offset -4
            byte    string.chars    // At offset 0

            // Note: characters in string occupy offsets
            // 0 ... in this structure

            ends    string

// str.buf
//
// Allocate storage for an empty string
// with the specified maximum size:

            .macro  str.buf strName, maxSize
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   \maxSize
            .word   0
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

// str.literal
//
// Allocate storage for a string buffer and initialize
// it with a string literal:

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

            // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
            .byte   0   // Zero-terminating byte

            // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

///////////////////////////////////////////////////////////

 .data
fmtStr:     .ascii      "Source string:\n\n"
            .ascii      "          1111111111222222222233333\n"
            .ascii      "01234567890123456789012345678901234\n"
            .asciz      "%s\n\n"

            str.buf     smallDest, 32
            str.literal dest,   "Initial destination string"

//                             1111111111222222222233333
//                   01234567890123456789012345678901234
str.literal source, "Hello there, world! How's it going?"

///////////////////////////////////////////////////////////

            .code

ttlStr:     wastr  "listing14-5"

// Standard getTitle function
// Returns pointer to program name in X0

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

///////////////////////////////////////////////////////////
//
// str.substr
//
// Extracts a substring
//
// On entry:
//
//  X0- Pointer to source string
//  W1- Starting index into source string
//  W2- Length of substring
//  X3- Destination string
//
// On exit:
//
//  Carry clear on success and result stored at X3
//
//  If the substring will not fit in X3, return with
//  the carry set (and no data copied).

            proc    str.substr

            locals  str_substr
            qword   str_substr.saveV0
            qword   str_substr.saveX0X1
            qword   str_substr.saveX2X3
            qword   str_substr.saveX6X7
 byte    str_substr.stkSpace,64  // Not needed
            endl    str_substr

            enter   str_substr.size

            // Preserve X0 ... X7 and V0:

            str     q0,     [fp, #str_substr.saveV0]
            stp     x0, x1, [fp, #str_substr.saveX0X1]
            stp     x2, x3, [fp, #str_substr.saveX2X3]
            stp     x6, x7, [fp, #str_substr.saveX6X7]

            // Handle the exceptional conditions:
            //
            // 1\. Index >= source.len   (return empty string)

          ❶ ldr     w6, [x0, #string.len]
            cmp     w1, w6
            bhs     returnEmpty

            // 2\. Index + substr length > source length
            // If so, reduce the length to match the end
            // of the string:

          ❷ add     w7, w1, w2      // W7 = index + substr length
            cmp     w6, w7
            csel    w6, w6, w7, ls  // W6 = min(source len, sum)
            sub     w6, w6, w1      // W6 = actual length

            // 3\. Substr length > destination maxlen
            //    (fail):

          ❸ ldr     w7, [x3, #string.maxlen]
            cmp     w6, w7          // Carry set if
            bhi     str.sub.exit    // W6 >= W7.

            // At this point, W6 contains the actual number of
            // characters to copy from the source
            // to the destination. This could be less than the
            // length passed in W2 if the index + substr length
            // exceeded the length of the source string.

          ❹ str     w6, [x3, #string.len]   // Save as dest len.

            // Point X0 at the first character of the substring
            // to copy to the destination string (base address
            // plus starting index):

          ❺ add     x0, x0, w1, uxtw
            b.al    test16

 // Copy the substring 16 bytes at a time:

copy16:
          ❻ ldr     q0, [x0], #16   // Get bytes to copy.
            str     q0, [x3], #16   // Store into dest.

            // Decrement the number of characters to copy by
            // 16\. Quit if the result is negative (meaning
            // fewer than 16 characters were left to
            // copy). Remember, subs sets the flags the same
            // as cmp, so the following compares the value in
            // W6 against 16 and branches to copy16 if
            // 16 or more characters are left to copy:
test16:
            subs    w6, w6, #16
            bhs     copy16

            // W6 has gone negative. Need to add 16 to determine
            // the number of bytes left to copy:

          ❼ add     w6, w6, #16     // Now W6 contains 0 to 15.

            // Switch statement based on the number of characters
            // left to copy in the substring. Handle as a special
            // case each of the 0 ... 15 bytes to copy:

            and     x6, x6, #0xFFFFFFFF  // Zero-extend to 64 bits.
            adr     x7, JmpTbl
            ldr     w6, [x7, x6, lsl #2] // *4 for 32-bit entries
            add     x7, x7, w6, sxtw     // Sign-extend to 64 bits.
            br      x7

JmpTbl:     .word   str.sub.success-JmpTbl  // _0bytesToCopy
            .word   _1byteToCopy-JmpTbl
            .word   _2bytesToCopy-JmpTbl
            .word   _3bytesToCopy-JmpTbl
            .word   _4bytesToCopy-JmpTbl
            .word   _5bytesToCopy-JmpTbl
            .word   _6bytesToCopy-JmpTbl
            .word   _7bytesToCopy-JmpTbl
            .word   _8bytesToCopy-JmpTbl
            .word   _9bytesToCopy-JmpTbl
            .word   _10bytesToCopy-JmpTbl
            .word   _11bytesToCopy-JmpTbl
            .word   _12bytesToCopy-JmpTbl
            .word   _13bytesToCopy-JmpTbl
            .word   _14bytesToCopy-JmpTbl
            .word   _15bytesToCopy-JmpTbl

// Special case copying 1-15 bytes:

❽ _14bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8
_6bytesToCopy:
            ldr     w7, [x0], #4
            str     w7, [x3], #4

_2bytesToCopy:
            ldrh    w7, [x0], #2
            strh    w7, [x3], #2
            b.al    str.sub.success

_13bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8

_5bytesToCopy:
            ldr     w7, [x0], #4
            str     w7, [x3], #4
            ldrb    w7, [x0], #1
            strb    w7, [x3], #1
            b.al    str.sub.success

_12bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8

_4bytesToCopy:
            ldr     w7, [x0], #4
            str     w7, [x3], #4
            b.al    str.sub.success

_11bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8
            ldrh    w7, [x0], #2
            strh    w7, [x3], #2
            ldrb    w7, [x0], #1
            strb    w7, [x3], #1
            b.al    str.sub.success

_10bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8
            ldrh    w7, [x0], #2
            strh    w7, [x3], #2
            b.al    str.sub.success

_9bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8
            ldrb    w7, [x0], #1
            strb    w7, [x3], #1
            b.al    str.sub.success

_8bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8
            b.al    str.sub.success

_15bytesToCopy:
            ldr     x7, [x0], #8
            str     x7, [x3], #8

_7bytesToCopy:
            ldr     w7, [x0], #4
            str     w7, [x3], #4

_3bytesToCopy:
            ldrh    w7, [x0], #2
            strh    w7, [x3], #2

_1byteToCopy:
            ldrb    w7, [x0], #1
            strb    w7, [x3], #1

// Branch here after copying all string data.
// Need to add a zero-terminating byte to the
// end of the destination string:

str.sub.success:
          ❾ strb    wzr, [x3]       // Zero-terminating byte
            adds    wzr, wzr, wzr   // Clear carry for success.

str.sub.exit:
            ldr     q0,     [fp, #str_substr.saveV0]
            ldp     x0, x1, [fp, #str_substr.saveX0X1]
            ldp     x2, x3, [fp, #str_substr.saveX2X3]
            ldp     x6, x7, [fp, #str_substr.saveX6X7]
            leave

// Special case where the code just returns an empty string:

returnEmpty:
          ❿ strh    wzr, [x3, #string.len]
            b.al    str.sub.success

            endp    str.substr

///////////////////////////////////////////////////////////
//
// testSubstr
//
//  Utility function to test call to str.substr
//
// On entry:
//  X0, X1, X2, X3 -- str.substr parameters

successStr: wastr   "substr('%s', %2d, %3d)= '%s'\n"
failureStr: wastr   "substr('%s', %2d, %3d) failed\n"

            proc    testSubstr

            locals  testSS
            byte    testSS.stkspace, 64
            endl    testSS

 enter   testSS.size

            lea     x5, successStr
            bl      str.substr
            bcc     success
            lea     x5, failureStr

success:
            mov     x4, x3
            mov     x3, x2
            mov     x2, x1
            mov     x1, x0
            mov     x0, x5
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            mstr    x3, [sp, #16]
            mstr    x4, [sp, #24]
            bl      printf
            leave
            endp    testSubstr

///////////////////////////////////////////////////////////
//
// Main program to test the code:

            proc    asmMain, public

            locals  lcl
            byte    stkSpace, 64
            endl    lcl

            enter   lcl.size      // Reserve space for locals.

            lea     x0, fmtStr
            lea     x1, source
            mstr    x1, [sp]
            bl      printf

            lea     x0, source
            mov     x1, #0
            mov     x2, #11
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #20
            mov     x2, #15
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #20
            mov     x2, #20
            lea     x3, dest
            bl      testSubstr

 lea     x0, source
            mov     x1, #40
            mov     x2, #20
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #0
            mov     x2, #100
            lea     x3, smallDest
            bl      testSubstr

AllDone:    leave
            endp    asmMain

str.substr函数首先通过查找必须处理的特殊情况开始。它首先检查起始索引值是否超出了源字符串的末尾;如果是,函数将返回空字符串作为结果 ❶。接下来,代码检查起始索引加上子字符串长度是否会超出源字符串的末尾;如果是,函数会将长度调整为不超过源字符串的末尾(而不会更远) ❷。最后,如果子字符串的长度大于目标字符串的最大长度,str.substr会立即返回,并将进位标志设置为表示错误条件 ❸。

如果没有特殊情况,代码就可以成功地将子字符串复制到目标字符串中。str.substr开始此过程时,会将目标字符串的长度设置为子字符串的长度 ❹。然后,代码从索引位置开始复制子字符串数据,首先通过将索引值加到字符串指针上来开始 ❺。

循环每次复制 16 字节,使用 V0 向量寄存器(Q0) ❻,只要剩余的字节数是 16 字节或更多。剩余字节数少于 16 字节时,代码会跳到 ❼,并将剩余长度值加 16(因为循环多减了一次 16)。

在加上 16 后,W6 将包含一个 0 到 15 之间的值,即剩余需要复制的字节数。代码本可以执行一个简单的循环,将剩余字节一个一个复制到目标位置,但那样会比较慢。相反,我选择执行一个(模拟的)switch语句,将控制权转移到 16 个标签之一❽,在这些标签的代码中执行直接的、暴力的字节复制。(为了减少代码大小,我尽可能地交错这些部分,重用各种代码序列。)

一旦它们复制了所需的字节数,所有这些代码序列会汇聚到❾(这也是如果剩余字节为 0 时,切换代码转移的位置)。这段代码将一个零终止字节附加到字符串的末尾,清除进位标志,并返回给调用者。

这段代码处理了str.substr返回空字符串的特殊情况,因为索引值大于源字符串的长度❿。这段代码将目标字符串的长度设置为 0,然后转移到❾以零终止字符串并返回成功。asmMain函数调用了一个特殊的辅助函数(testSubstr)来执行各种测试并打印结果。

子字符串的起始位置极不可能位于 16 字节边界上。因此,当清单 14-5 中的函数每次从源字符串中提取 16 个字节时,它很可能会发生未对齐的内存访问(这会更慢)。在不写大量代码的情况下,你对此几乎无能为力,只能接受执行会略微变慢这一事实。因为访问可能不对齐 16 字节边界,所以这段代码只能复制指定数量的字节(绝不读取超出源字符串结尾的内容),以确保不会访问到不合适的内存页面。

下面是清单 14-5 中程序的构建命令和示例输出:

% ./build Listing14-5
% ./Listing14-5
Calling Listing14-5:
Source string:

          1111111111222222222233333
01234567890123456789012345678901234
Hello there, world! How's it going?

substr('Hello there, world! How's it going?',  0,  11)= 'Hello there'
substr('Hello there, world! How's it going?', 20,  15)= 'How's it going?'
substr('Hello there, world! How's it going?', 20,  20)= 'How's it going?'
substr('Hello there, world! How's it going?', 40,  20)= ''
substr('Hello there, world! How's it going?',  0, 100) failed
Listing14-5 terminated

尽管这不是一个详尽无遗的测试,但这份输出足以展示str.substr的基本操作。

14.2.5 更多字符串函数

当然,任何优秀的字符串库都有许多额外的字符串函数。str.len函数是迄今为止最明显缺失的函数。这个函数的实现应该非常直观:只需从字符串数据结构中获取string.len字段。即使忽略这个疏漏,仍然有几十个你可能希望使用的其他字符串函数(例如,HLA 标准库提供了超过 200 个字符串函数)。

不幸的是,本书没有空间描述完整的字符串库函数。在阅读完本章后,你应该具备自己实现任何所需字符串函数的技能。有关更多资源,可以参见第 14.6 节“更多信息”,位于第 859 页。

14.3 Unicode 字符集

本书到目前为止的所有代码示例都假设汇编语言中的字符串是由 ASCII 字符组成的,主要是因为 Gas 并不直接支持 Unicode。然而,Linux 和 macOS 系统通常使用 Unicode(尽管 ASCII 是 Unicode 的一个子集)。现在你已经了解了如何为 ASCII 字符实现字符串函数,是时候扩展 第二章 对 Unicode 的简要介绍,并讨论 Unicode 字符串的字符串函数了。

14.3.1 Unicode 历史

几十年前,Aldus 公司、NeXT、Sun Microsystems、Apple Computer、IBM、Microsoft、研究图书馆小组和 Xerox 的工程师们意识到,他们的新计算机系统配备了位图和用户可选择的字体,可以一次显示远超过 256 个字符。当时,双字节字符集(DBCSs) 是最常见的解决方案。

然而,DBCSs 有一些问题。首先,通常它们是变长编码,需要特殊的库代码;依赖于固定长度字符编码的常见字符或字符串算法无法与它们正常工作。其次,没有统一的标准;不同的 DBCSs 对不同的字符使用相同的编码。

为了避免这些兼容性问题,工程师们寻求了不同的解决方案。他们提出了 Unicode 字符集,最初使用 2 字节字符大小。像 DBCSs 一样,这种方法仍然需要特殊的库代码(现有的单字节字符串函数不总是能与 2 字节字符兼容)。然而,除了改变字符的大小之外,大多数现有的字符串算法仍然可以与 2 字节字符一起使用。Unicode 的定义包括了当时所有(已知或现存的)字符集,为每个字符分配了唯一的编码,以避免不同 DBCSs 所困扰的一致性问题。

原始的 Unicode 标准使用 16 位字来表示每个字符。因此,Unicode 支持最多 65,536 个字符编码——相比之下,8 位字节所能表示的 256 个编码是一个巨大的进步。此外,Unicode 向下兼容 ASCII。如果 Unicode 字符的二进制表示中的高 9 位为 0,则低 7 位使用标准的 ASCII 码。(ASCII 是 7 位码,因此如果 16 位 Unicode 值的高 9 位全为 0,剩余的 7 位就是字符的 ASCII 编码。)如果高 9 位包含非零值,则 16 位形成扩展字符编码,超出了 ASCII 字符集的范围。

你可能会想,为什么需要这么多字符编码。在 Unicode 最初开发时,某些亚洲字符集包含了 4,096 个字符。Unicode 字符集甚至提供了可以用来创建应用程序定义的字符集的编码。大约一半的 65,536 个可能的字符编码已经被定义,剩余的字符编码则保留用于未来的扩展。

今天,Unicode 已经成为一个通用字符集,长期以来取代了 ASCII 和较旧的 DBCS。所有现代操作系统(包括 macOS、Windows、Linux、Pi OS、Android 和 Unix)、所有网页浏览器以及大多数现代应用程序都提供对 Unicode 的支持。Unicode 联盟是一个非营利性机构,负责维护 Unicode 标准。通过维护这一标准,联盟帮助保证你在一个系统上输入的字符能够在另一个系统或应用程序中按预期显示。

14.3.2 码点和码平面

可惜的是,尽管最初的 Unicode 标准设计得非常周全,其创建者不可能预见到随后字符数量的爆炸性增长。表情符号、占星符号、箭头、指示符以及为互联网、移动设备和网页浏览器引入的各种符号——加上支持历史性、过时和稀有文字的需求——大大扩展了 Unicode 符号库。

在 1996 年,系统工程师发现 65,536 个符号已不足够。为了避免每个 Unicode 字符需要 3 或 4 字节的存储空间,负责 Unicode 定义的人放弃了创建固定大小字符表示法的想法,允许 Unicode 字符使用不透明的(且可变的)编码方式。如今,Unicode 定义了 1,112,064 个码点,远超最初为 Unicode 字符保留的 2 字节容量。

Unicode 的码点只是与特定字符符号关联的一个整数值;你可以把它看作是字符的 Unicode 等效的 ASCII 码。Unicode 码点的约定是以十六进制值表示,并加上 U+ 前缀。例如,U+0041 是字母 A 的 Unicode 码点。

65,536 个字符块在 Unicode 中被称为多语言平面。第一个多语言平面 U+000000 到 U+00FFFF 大致对应于最初的 16 位 Unicode 定义;Unicode 标准将其称为基本多语言平面(BMP)。平面 1(U+010000 到 U+01FFFF)、平面 2(U+020000 到 U+02FFFF)和平面 14(U+0E0000 到 U+0EFFFF)是补充平面。平面 3(U+030000 到 U+03FFFF)是第三象形文字平面(参见 <wbr>unicode<wbr>.org<wbr>/roadmaps<wbr>/tip<wbr>/)。Unicode 保留平面 4 到 13 用于未来扩展,平面 15 和 16 则用于用户定义字符集。

Unicode 标准定义了从 U+000000 到 U+10FFFF 范围内的码点。请注意,0x10FFFF 是 1,114,111,这就是大多数 1,112,064 个 Unicode 字符来自的地方;剩余的 2,048 个值则构成了代理码点

14.3.3 代理码点

如前所述,Unicode 起初是作为一个 16 位(2 字节)字符集编码的。当人们意识到 16 位不足以处理当时所有可能存在的字符时,扩展变得必要。从 Unicode v2.0 开始,Unicode 联盟扩展了 Unicode 的定义,加入了多字字符。现在,Unicode 使用代理代码点(U+D800 到 U+DFFF)来编码比 U+FFFF 更大的值,如图 14-1 所示。

图 14-1:Unicode 平面 1-16 的代理代码点编码

这两个词,单元 1(高代理)和单元 2(低代理),总是一起出现。单元 1 值的高位 0b110110 指定 Unicode 标量的上 10 位(b10 到 b19),而单元 2 值的高位 0b110111 指定 Unicode 标量的下 10 位(b0 到 b9)。因此,b16 到 b19 位加 1 的值指定了 Unicode 平面 1 到 16。b0 到 b15 位指定该平面内的 Unicode 标量值。

请注意,代理代码仅出现在基本多语言平面(BMP)中。其他的多语言平面不包含代理代码。从单元 1 和单元 2 值提取的 b0 到 b19 位总是指定一个 Unicode 标量值(即使这些值落在 U+D800 到 U+DFFF 范围内)。

14.3.4 字形、字符和字素簇

每个 Unicode 代码点都有一个唯一的名称。例如,U+0045 的名称是拉丁大写字母 A。符号 A 不是 字符的名称。A 是一个字形,它是设备为表示该字符所绘制的一系列笔画(一个水平笔画和两个斜笔画)。

对于单一的 Unicode 字符拉丁大写字母 A,存在许多字形。例如,Times Roman A 和 Times Roman Italic A 有不同的字形,但 Unicode 并不区分它们(也不区分任意两种字体中的 A 字符)。无论你使用什么字体或样式来绘制,字符拉丁大写字母 A 的 Unicode 始终是 U+0045。

当使用 ASCII 时,字符 这个术语有一个简单的含义。字符代码 0x41 对应于拉丁大写字母 A,当它出现在显示屏上时有一个一致的表现;特别是,ASCII 字符代码与用户期望在屏幕上看到的符号之间存在一一对应关系。而当使用 Unicode 时,情况复杂得多。在这里,通常所说的字符和 Unicode 字符的定义(在 Unicode 术语中是标量)之间是有区别的。例如,考虑以下的 Swift 代码:

import Foundation
let eAccent  :String = "e\u{301}"
print(eAccent)
print("eAccent.count=\(eAccent.count)")
print("eAccent.utf16.count=\(eAccent.utf16.count)")

本节使用 Swift 编程语言作为示例,因为它是第一个尝试正确处理 Unicode 的编程语言之一(尽管这样做会导致巨大的性能损失)。另一方面,汇编语言要求程序员手动处理所有内容,并且对于许多 Unicode 示例来说并不是最佳工具。我将在第 14.4 节“汇编语言中的 Unicode”中解释如何将其转换为汇编代码,请参见第 853 页。

在字符串中指定 Unicode 标量值的 Swift 语法是“\u{hex}”,其中 hex 是一个十六进制值,例如“\u{301}”。在这个示例中,301 是组合尖音符字符的十六进制代码。第一个 print() 语句打印字符,输出 é,正如你所期望的那样。第二个 print() 语句打印 Swift 确定字符串中存在的字符数——在这种情况下是 1。第三个 print() 语句打印字符串中的元素数量(UTF-16 元素,本节后面会进一步讨论)。在这种情况下,该数字是 2,因为该字符串包含 2 个 UTF-16 数据单元。

在这个例子中,é 是一个字符还是两个?在内部(假设使用 UTF-16 编码),计算机为这个单一字符分配了 4 字节的内存(两个 16 位的 Unicode 标量值)。然而,在屏幕上,输出只占一个字符位置,并且对用户来说看起来像一个字符。当这个字符出现在文本编辑器中,并且光标紧挨着该字符时,用户会期望按下 BACKSPACE 删除它。因此,从用户的角度来看,这就是一个字符(正如 Swift 在你打印字符串的 count 属性时所报告的那样)。

然而,在 Unicode 中,字符在很大程度上等同于代码点。在 Unicode 术语中,当你谈论应用程序向最终用户显示的符号时,你将其称为字形簇,而不是字符。这些是一个或多个 Unicode 代码点的序列,它们组合在一起形成一个单一的语言元素(即,在显示器上以单个字符的形式呈现给用户的内容,例如 é)。

14.3.5 规范形式与规范等价性

实际上,Unicode 字符 é 在 Unicode 出现之前就已经存在于个人计算机中:它是原始 IBM PC 字符集和 Latin-1 字符集的一部分(例如,在旧的 DEC 终端上使用)。Unicode 使用 Latin-1 字符集来表示 U+00A0 到 U+00FF 范围内的代码点,而 U+00E9 恰好对应于 é 字符。因此,你可以如下修改之前的程序:

import Foundation
let eAccent  :String = "\u{E9}"
print(eAccent)
print("eAccent.count=\(eAccent.count)")
print("eAccent.utf16.count=\(eAccent.utf16.count)")

这个程序的输出如下:

é
eAccent.count=1
eAccent.utf16.count=1

哎哟!你现在有几个字符串,它们都生成 é,但包含的代码点数量不同。想象一下,这会如何使得包含 Unicode 字符的字符串编程变得复杂。例如,如果你尝试比较以下三个字符串(Swift 语法),结果会是什么呢?

let eAccent1 :String = "\u{E9}"
let eAccent2 :String = "e\u{301}"

对于用户而言,这两个字符串在屏幕上看起来是一样的。然而,它们显然包含不同的值。如果你比较它们以判断它们是否相等,结果是 true 还是 false?

最终,这取决于你使用的是哪种字符串库。大多数当前的字符串库在比较这些字符串是否相等时会返回 false。许多语言的字符串库简单地报告这两个字符串不相等。

两个 Unicode/Swift 字符串 "{E9}" 和 "e{301}" 应该在显示器上输出相同的内容。因此,根据 Unicode 标准,它们是规范等效的。一些字符串库不会报告这些字符串是等效的。有些库,比如与 Swift 一起使用的库,会处理小的规范等价(如 "{E9}" == "e{301}"),但不会处理应当等价的任意序列。(这可能是正确性与效率之间的良好平衡;处理所有不常发生的奇怪情况,如 "e{301}{301}",在计算上是很昂贵的。)

Unicode 为 Unicode 字符串定义了规范形式。规范形式的一个方面是将规范等效的序列替换为等效序列——例如,将 "e\u{309}" 替换为 "\u{E9}",反之亦然(通常更短的形式更为优选)。某些 Unicode 序列允许多个组合字符。通常,组合字符的顺序对生成所需的字形簇无关紧要。然而,如果组合字符按照指定的顺序排列,比较两个这样的字符会更容易。规范化 Unicode 字符串也可能产生其组合字符始终以固定顺序出现的结果,从而提高字符串比较的效率。

14.3.6 编码

从 Unicode 2.0 开始,标准支持一个 21 位字符空间,能够处理超过一百万个字符(尽管大多数代码点仍然保留供未来使用)。Unicode 联盟并没有使用固定大小的 3 字节(或更糟,4 字节)编码来支持更大的字符集,而是允许使用不同的编码:UTF-32、UTF-16 和 UTF-8(UTF 代表 Unicode 转换格式)。这三种编码各有优缺点。

UTF-32 使用 32 位整数来表示 Unicode 标量值。这种方案的优点是,32 位整数可以用 21 位表示每个 Unicode 标量值。需要对字符串中的字符进行随机访问的程序——无需查找代理对——以及其他常数时间操作,在使用 UTF-32 时通常是可能的。UTF-32 的显著缺点是每个 Unicode 标量值需要 4 字节的存储空间——是原始 Unicode 定义的两倍,也是 ASCII 字符的四倍。

使用比 ASCII 和原始 Unicode 多两到四倍的存储空间(即使用 UTF-32)似乎是一个小代价。毕竟,现代计算机的存储空间远比 Unicode 最初出现时大几个数量级。然而,这额外的存储空间对性能有巨大影响,因为这些额外的字节会迅速消耗缓存存储。此外,现代字符串处理库通常每次操作 8 字节(在 64 位计算机上)。对于 ASCII 字符,这意味着一个给定的字符串函数可以同时处理最多八个字符;而对于 UTF-32,相同的字符串函数只能同时处理两个字符。因此,UTF-32 版本的速度将是 ASCII 版本的四倍慢。最终,即便是 Unicode 标量值也不足以表示所有 Unicode 字符(即许多 Unicode 字符需要一系列 Unicode 标量值),因此使用 UTF-32 并不能解决问题。

正如其名称所示,Unicode 支持的第二种编码格式,UTF-16,使用 16 位(无符号)整数表示 Unicode 值。为了处理大于 0xFFFF 的标量值,UTF-16 使用代理对方案表示 0x010000 到 0x10FFFF 范围内的值。因为绝大多数有用的字符适合于 16 位表示,所以大多数 UTF-16 字符仅需要 2 字节。对于那些需要代理的少数情况,UTF-16 需要两个字(32 位)来表示字符。

最后的编码格式,也是无疑最流行的,是 UTF-8。UTF-8 编码向后兼容 ASCII 字符集。特别是,所有的 ASCII 字符都有单字节表示(它们的原始 ASCII 代码,其中包含该字符的字节的高位(HO)为 0)。如果 UTF-8 的高位(HO)为 1,则 UTF-8 需要 1 到 3 个附加字节来表示 Unicode 代码点。

表 14-1 提供了 UTF-8 编码方案,其中的 x 位是 Unicode 点位的位。

表 14-1:UTF-8 编码

字节数 代码点的位数 第一个代码点 最后一个代码点 字节 1 字节 2 字节 3 字节 4
1 7 U+00 U+7F 0xxxxxxx
2 11 U+80 U+7FF 110xxxxx 10xxxxxx
3 16 U+800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 21 U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

对于多字节序列,字节 1 包含高位(HO)位,字节 2 包含接下来的高位(与字节 1 的低位(LO)比较),依此类推。例如,2 字节序列(0b11011111, 0b10000001)对应的 Unicode 标量值是 0b0000_0111_1100_0001(U+07C1)。

UTF-8 编码可能是最常用的编码格式,因为大多数网页都使用它。大多数 C 标准库的字符串函数会在 UTF-8 文本上无修改地运行(尽管如果程序员不小心,某些函数可能会产生格式错误的 UTF-8 字符串)。

不同的语言和操作系统默认使用不同的编码。例如,macOS 和 Windows 倾向于使用 UTF-16 编码,而大多数 Unix 系统使用 UTF-8。某些 Python 变种使用 UTF-32 作为它们的本地字符格式。总的来说,大多数编程语言使用 UTF-8,因为它们可以继续使用基于 ASCII 的旧字符处理库来处理 UTF-8 字符。

14.3.7 组合字符

尽管 UTF-8 和 UTF-16 编码比 UTF-32 更加紧凑,但处理多字节(或多字)的字符集所需的 CPU 开销和算法复杂性使得它们的使用变得更加复杂,容易引入 bug 和性能问题。尽管存在浪费内存,特别是在缓存中,为什么不干脆将字符定义为 32 位实体并解决问题呢?这似乎能简化字符串处理算法,提高性能,并减少代码缺陷的可能性。

这个理论的问题在于,你不能仅凭 21 位(甚至 32 位)存储来表示所有可能的字形簇。许多字形簇由多个连接的 Unicode 代码点组成。以下是来自 Chris Eidhof 和 Ole Begemann 的《高级 Swift》,版本 3.0(CreateSpace,2017)中的一个示例:

let chars: [Character] = [
    "\u{1ECD}\u{300}",
    "\u{F2}\u{323}",
    "\u{6F}\u{323}\u{300}",
]
print(chars)

每个这些 Unicode 字形簇都会产生相同的输出:Ò.(来自约鲁巴字符集的一个字符)。字符序列(U+1ECD,U+300)是一个 o,后跟一个组合的尖音符。字符序列(U+F2,U+323)是一个ò,后跟一个组合的点。字符序列(U+6F,U+323,U+300)是一个 o,后跟一个组合的点,再后跟一个组合的尖音符。

Swift 字符串比较将这四个字符串视为相等:

print("\u{1ECD} + \u{300} = \u{1ECD}\u{300}")
print("\u{F2} + \u{323} = \u{F2}\u{323}")
print("\u{6F} + \u{323} + \u{300} = \u{6F}\u{323}\u{300}")
print("\u{6F} + \u{300} + \u{323} = \u{6F}\u{300}\u{323}")
print(chars[0] == chars[1]) // Outputs true.
Print(chars[0] == chars[2]) // Outputs true.
print(chars[0] == chars[3]) // Outputs true.
Print(chars[1] == chars[2]) // Outputs true.
print(chars[1] == chars[3]) // Outputs true.
Print(chars[2] == chars[3]) // Outputs true.

没有单一的 Unicode 标量值能够产生这个字符。你必须至少组合两个 Unicode 标量(或最多三个)来在输出设备上产生这个字形簇。即使是 UTF-32 编码,也仍然需要两个(32 位)标量来产生这个特定的输出。

表情符号带来了另一个挑战,无法通过 UTF-32 解决。考虑 Unicode 标量 U+1F471。它显示一个金发人的表情符号。如果你添加一个肤色修饰符,你会得到(U+1F471,U+1F3FF),这会产生一个肤色较深的金发人。在这两种情况下,屏幕上显示的是一个字符。第一个例子使用单一的 Unicode 标量值,但第二个例子需要两个。没有办法用单一的 UTF-32 值来编码这个。

最终结论是,某些 Unicode 字形簇需要多个标量,不管你为标量分配多少位(例如,可能会将 30 或 40 个标量合并成一个字形簇)。这意味着你不得不处理多个词序列来表示一个“字符”,无论你多么努力地避免这种情况。这也是为什么 UTF-32 从未真正流行起来的原因:它并没有解决对 Unicode 字符串进行随机访问的问题。在规范化和组合 Unicode 标量时,使用 UTF-8 或 UTF-16 编码会更高效。

再次强调,今天大多数语言和操作系统都以某种形式支持 Unicode(通常使用 UTF-8 或 UTF-16 编码)。尽管处理多字节字符集存在显而易见的问题,现代程序需要处理 Unicode 字符串,而不是简单的 ASCII 字符串。

14.4 汇编语言中的 Unicode

如第 2.17 节“Gas 对 Unicode 字符集的支持”中所述,参见 第 102 页,Gas 对 Unicode 字符串的支持并不特别好。如果你有一个支持输入 Unicode 文本的文本编辑器,你可能能够在字符串常量中输入非 ASCII 的 UTF-8 字符,并且 Gas 能够接受它们。然而,通常来说,将非 ASCII Unicode 字符插入到汇编语言源文件中最安全的方式是使用十六进制常量。本节介绍如何从控制台应用程序输出 Unicode 字符,并简要介绍 Unicode 字符串函数。

14.4.1 编写支持 UTF-8 字符的控制台应用程序

为了能够打印包含 UTF-8 字符的字符串,你必须确保你的操作系统能够接受它们。通常通过使用 C 标准库中的 setlocale() 函数来实现。不幸的是,参数列表根据区域设置有所不同,因此我无法提供一个适用于所有环境的通用示例。对于美国英语,我通常使用以下函数调用:

setlocale(LC_ALL, "en_US.UTF-8");

第二个参数的具体字符串会根据国家和语言的不同而有所变化。你可以在线搜索 setlocale() 函数的描述,了解更多有关调用该函数的详细信息(或者参见第 14.6 节“更多信息”,见 第 859 页)。以下是列出你系统可用区域设置字符串的 Linux/macOS 命令:

locale -a

以下是此命令在 macOS 上(在 Mac mini M1 上)生成的一些字符串:

C                   en_NZ.ISO8859-1     it_IT
POSIX               en_NZ.ISO8859-15    it_IT.ISO8859-1
af_ZA               en_NZ.US-ASCII      it_IT.ISO8859-15
af_ZA.ISO8859-1     en_NZ.UTF-8         it_IT.UTF-8
af_ZA.ISO8859-15    en_US               ja_JP

`Many entries snipped ...`

zh_TW.Big5
zh_TW.UTF-8

有关区域设置字符串格式的解释,请参见“更多信息”部分。

你可以从你的汇编语言代码中调用 setlocale(),但我发现修改 c.cpp 程序更为方便,这个程序是 build 脚本使用的。以下展示了这个修改:

// c-utf8.cpp
//
// (Rename to c.cpp to use with build script.)
//
// Generic C++ driver program to demonstrate returning function
// results from assembly language to C++. Also includes a
// "readLine" function that reads a string from the user and
// passes it on to the assembly language code.
//
// Need to include stdio.h so this program can call "printf"
// and stdio.h so this program can call strlen.

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <locale.h>

// extern "C" namespace prevents "name mangling" by the C++
// compiler.

extern "C"
{
    // asmMain is the assembly language code's "main program":

    void asmMain(void);

    // getTitle returns a pointer to a string of characters
    // from the assembly code that specifies the title of that
    // program (which makes this program generic and usable
    // with a large number of sample programs in "The Art of
    // ARM Assembly Language").

    char *getTitle(void);

    // C++ function that the assembly
    // language program can call:

    int readLine(char *dest, int maxLen);

};

// readLine reads a line of text from the user (from the
// console device) and stores that string into the destination
// buffer the first argument specifies. Strings are limited in
// length to the value specified by the second argument
// (minus 1).
//
// This function returns the number of characters actually
// read, or -1 if there was an error.
//
// Note that if the user enters too many characters (maxlen or
// more), this function returns only the first maxlen - 1
// characters. This is not considered an error.

int readLine(char *dest, int maxLen)
{
    // Note: fgets returns NULL if there was an error, else
    // it returns a pointer to the string data read (which
    // will be the value of the dest pointer).

    char *result = fgets(dest, maxLen, stdin);
    if(result != NULL)
    {
        // Wipe out the newline character at the
        // end of the string:

        int len = strlen(result);
        if(len > 0)
        {
            dest[len - 1] = 0;
        }
 return len;
    }
    return -1; // If there was an error
}

int main(void)
{
    // Get the assembly language program's title:

    char *title = getTitle();

    setlocale(LC_ALL, "en_US.UTF-8");
    asmMain();
    printf("%s terminated\n", title);
}

清单 14-6 展示了一个简单的程序,演示了包含 UTF-8 的示例文本输出,需与 c-utf8.cpp 编译和链接。此示例打印出 UTF-8 序列 U+65(小写字母 e)后跟 U+301(组合急性重音符号)。

// Listing14-6.S
//
// Simple program to demonstrate UTF-8 output

            #include    "aoaa.inc"

            .data
fmtStr:     .ascii  "Unicode='"

            // e followed by U+301 (0xCC, 0x81 in UTF-8)

            .ascii  "e"
            .byte   0xCC, 0x81

            .asciz  "'\n"

            .code
ttlStr:     wastr  "Listing14-6.S"

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

            proc    asmMain, public

            locals  lcl
            qword   saveX20_X21
            byte    stkSpace, 64
            endl    lcl

            enter   lcl.size      // Reserve space for locals.

 lea     x0, fmtStr
            bl      printf

AllDone:    leave
            endp    asmMain

请注意,U+301 的 UTF-8 编码需要 2 个字节

1 1 0 b11 b10 b9 b8 b7,    1 0 b6 b5 b4 b3 b2 b1 b0

其中 B[11]到 B[0]是 0x301 或 0b011_0000_0001。因此,两个 UTF-8 字节是 0b1100_1100(0xCC)和 0b10000001(0x81)。

这是第 14-6 节的构建命令和示例输出(假设c-utf8.cpp已重命名为c.cpp):

% ./build Listing14-6
% ./Listing14-6
Unicode='é'
Listing14-6.S terminated

如你所见,字符序列 e, 0xcc, 0x89 产生了带重音符号的é字符。

14.4.2 使用 Unicode 字符串函数

只要你坚持使用 UTF-8 编码,操作 ASCII 字符串的字符字符串函数大多数能与 Unicode 字符串一起工作。不过,你应该意识到一些问题:

  • 除非你将字符串保持在规范形式,否则某些字符串比较可能会报告两个字符串不相等,而实际上,它们对读者来说是相等的。

  • 对于小于大于的字符串比较,可能会产生非直观的结果,因为 ASCII 比较在面对那些值占用 2 个或更多字节的 Unicode 标量时表现不佳。

  • 字符串长度计算(当使用零终止或汇编语言字符串数据类型时)将报告字符串中的字节数,而不是字符(标量或字形)数。除非字符串仅包含 ASCII 字符,否则长度计算会是错误的。计算 Unicode 字符串中字符的唯一合理方法是一次处理一个字形并计算字形数。

  • 接受字符串索引的函数通常需要字形索引,而不是字节索引。例如,前面给出的 str.substr 函数,如果索引和长度参数选择不当,可能会提取包含字形部分的子字符串,或者在字符串的末尾切割一个字形。

由于这些问题(以及其他问题),在 Unicode 和 UTF-8 字符串上使用基于 ASCII 的字符串函数是危险的。不言而喻,基于 ASCII 的函数在 UTF-16 或 UTF-32 编码的 Unicode 字符上是无法工作的。

下一页的第 14.6 节,“更多信息”,提供了几个处理 Unicode 字符串的字符串库的链接(大多是用 C/C++编写的)。国际 Unicode 组件(UCI)库是值得考虑的重要库,因为它是由 Unicode 联盟提供的。写作时,这个库(ICU74.2)宣称支持以下内容:

  • 最新版本的 Unicode 标准

  • 支持超过 220 个代码页的字符集转换

  • 超过 300 种区域设置的区域数据

  • 基于 Unicode 排序算法(ISO 14651)的语言敏感文本排序和搜索

  • 正则表达式匹配和 Unicode 集合

  • 用于标准化、大写和小写转换、脚本转写(50+对)

  • 用于存储和访问本地化信息的资源包

  • 文化特定输入/输出格式的日期/数字/消息格式化和解析

  • 日历特定的日期和时间操作

  • 文本边界分析,用于查找字符、单词和句子的边界

尽管这不是典型编程语言中期望的完整字符串函数集合,但它提供了实现完整功能所需的所有基本操作。还请记住,Unicode 字符串函数并不是特别快速。不幸的是,由于 Unicode 字符集(以及一般的多字节字符集)的设计,必须处理字符串中的每个字符才能完成日常任务。只有少数函数,如 str.cpy,可以在不扫描每个字符的情况下工作。

14.5 继续前进

本章介绍了字符串数据结构(零终止和特殊汇编语言字符串)、从汇编语言调用 C 标准库字符串函数、编写基于汇编语言的字符串函数,以及使用 Unicode 字符集(和 Unicode 字符串函数)。

下一章将讨论如何在汇编语言中管理大型项目,特别是如何创建库模块,这对于将多个字符串函数组合成一个库模块非常有用。

14.6 更多信息

第十五章:15 管理复杂项目

大多数汇编语言源文件并不是独立的程序。通常,你必须调用各种标准库或其他例程,而这些例程并未在你的主程序中定义,因为试图将这样的代码写入你的应用程序中会工作量过大(而且是糟糕的编程实践)。

例如,ARM 并没有提供像读、写或放置等用于 I/O 操作的机器指令。本书中的函数包含了成千上万行的源代码来完成这些操作。对于小程序,使用单个源文件没有问题,但对于大型程序来说,这就变得很繁琐。如果你必须将成千上万行代码合并到你的简单程序中,这将使编程变得相当困难,而且编译速度也会变得很慢。此外,一旦你调试并测试了程序的一个大型部分,在对程序的其他部分进行小改动时,还需要重新组装这部分代码,简直是浪费时间。试想一下,在一台快速的 PC 上,仅仅做了一行代码的修改后,你可能要等 20 或 30 分钟才能重新组装程序!

大型编程是软件工程师用来描述减少大型软件项目开发时间的过程、方法和工具的术语。虽然每个人对“庞大”的定义不同,分离编译是支持大型编程的更流行技术之一。首先,你将大型源文件拆分成可管理的块。然后你将单独的文件编译成目标代码模块。最后,你将目标模块链接在一起形成完整的程序。如果你需要对其中一个模块进行小改动,你只需要重新组装那个模块,而不是整个程序。

本章描述了 Gas 和操作系统提供的分离编译工具,以及如何有效地在程序中使用这些工具。

15.1 .include 指令

.include 指令在源文件中出现时,会将程序输入从当前文件切换到 .include 指令操作数字字段中指定的文件。第 1.5 节,“aoaa.inc 包含文件”,在 第 10 页 中描述了 .include 指令,它允许你将来自不同源文件的代码包含到当前汇编中,从而构建包含公共常量、类型、源代码和其他 Gas 项目的文本文件。正如该节中所提到的,.include 指令的语法是:

.include "`filename`"

其中,文件名必须是一个有效的文件名。

根据本书的约定,Gas 包含文件有 .inc(包含)后缀。然而,Gas 并不要求包含文件必须有这个后缀;任何包含 Gas 汇编语言源代码的文件名都可以使用。Gas 会在 .include 指令的位置将指定的文件合并到编译过程中。你可以在包含的文件中嵌套 .include 语句;也就是说,在汇编过程中被包含进另一个文件的文件可以再包含第三个文件。

单独使用 .include 指令并不会提供独立编译。你可以使用 .include 将一个大源文件拆分成多个模块,在编译时将这些模块合并。以下示例将在编译程序时包含 print.incgetTitle.inc 文件:

.include  "print.inc"
.include  "getTitle.inc"

现在你的程序将从模块化中受益。遗憾的是,你并不会节省任何开发时间。 .include 指令会在编译时将源文件插入到 .include 的位置,正如你亲自输入了这些代码一样。Gas 仍然需要编译代码,而这需要时间。如果你要将大量源文件(比如一个庞大的库)包含到你的汇编中,编译过程可能需要永远

通常情况下,你不应该使用 .include 指令来包含源代码,如前面的示例所示,因为这样做的代码无法利用独立编译。相反,应该使用 .include 指令将一组公共常量、类型、外部过程声明及其他类似项目插入到程序中。通常,汇编语言的包含文件不包含任何机器代码(宏之外;有关详细信息,请参见第十三章)。以这种方式使用 .include 文件的目的将在你看到外部声明如何工作后变得更加清晰(请参见下一页第 15.3 节,“汇编单元与外部指令”)。

如果你的汇编语言源文件有 .S 后缀,你也可以使用 #include "filename" 指令来包含源文件。这通常更可取,因为你可以在这样的包含文件中使用 CPP 指令(而在标准 .include 文件中无法使用)。本章的其余部分假定使用 #include 指令,而不是 .include。

15.2 忽略重复的包含操作

当你开始开发复杂的模块和库时,最终你会发现一个大问题:有些头文件需要包含其他头文件。技术上来说,这本身并没有问题,但问题出现在一个头文件包含了另一个头文件,而那个第二个头文件又包含了另一个头文件,依此类推,最终导致最后一个头文件又包含了第一个头文件。

头文件间接包含自身有两个问题。首先,这会在编译器中创建一个无限循环。编译器会不断地重复包含这些文件,直到内存耗尽或发生其他错误。其次,当 Gas 第二次包含头文件时,它会开始对重复的符号定义进行强烈抱怨。毕竟,第一次读取头文件时,它处理了文件中的所有声明;第二次读取时,它将所有这些符号视为重复符号。

解决递归包含文件的标准技术,是使用条件汇编让 Gas 忽略包含文件的内容,这一点 C/C++程序员十分熟悉。(请参见第十三章,了解有关 CPP 和 Gas CTL 的条件汇编讨论。)技巧是将一个#ifdef(如果已定义)语句放在包含文件的所有语句周围。指定一个未定义的符号作为#ifdef 操作数(我通常使用包含文件的文件名,将句点替换为下划线)。然后,在#ifdef 语句之后立即定义该符号;通常使用数字等式,并将符号赋值为常量 1。以下是该#ifdef 用法的一个示例:

#ifdef  myinclude_inc   // Filename: myinclude.inc
#define myinclude_inc 1

 `Put all the source code lines for the include file here.`

// The following statement should be the last nonblank line
// in the source file:

#endif  // myinclude_inc

如果你尝试第二次包含myinclude.inc,#ifdef 指令将导致 Gas(实际上是 CPP)跳过所有文本,直到对应的#endif 指令,从而避免重复定义错误。

15.3 汇编单元与外部指令

汇编单元是源文件及其直接或间接包含的任何文件的集合。一个汇编单元在汇编后生成一个单独的.o(目标)文件。链接器将多个目标文件(由 Gas 或其他编译器,如 GCC 生成)合并为一个可执行文件。本节的主要目的,实际上也是整章的目的,是描述这些汇编单元(.o文件)在链接过程中如何相互传递链接信息。汇编单元是创建汇编语言模块化程序的基础。

要使用 Gas 的汇编单元功能,你必须创建至少两个源文件。一个文件包含第二个文件使用的一组变量和过程。第二个文件使用这些变量和过程,而无需知道它们是如何实现的。

从技术上讲,#include 指令为您提供了创建此类模块化程序所需的所有功能。您可以创建多个模块,每个模块包含一个特定的例程,并通过使用#include 指令根据需要将这些模块包含到您的汇编语言程序中。然而,如果您使用这种方法,在编译时包含一个已调试的例程仍然会浪费时间,因为每当您汇编主程序时,Gas 都必须重新编译无错误的代码。一个更好的解决方案是预先汇编已调试的模块,并将目标代码模块链接在一起,使用 Gas 的.global 和.extern 指令,本节将介绍这些内容。

本书到目前为止出现的所有程序都是独立的模块,这些模块链接到一个 C/C++主程序,而不是另一个汇编语言模块。在每个程序中,汇编语言的“主程序”都命名为 asmMain,它实际上是一个与 C++兼容的函数,这个函数是由通用的c.cpp程序从其主程序中调用的。例如,考虑第 9 页中的 Listing 1-3 中的 asmMain 主体(适用于 Linux 和 Pi OS 系统):

// Listing1-3.S
//
// A simple Gas module that contains
// an empty function to be called by
// the C++ code in Listing 1-2.

 .text

// Here is the asmMain function:

        .global asmMain
        .align  2    // Guarantee 4-byte alignment.
asmMain:

// Empty function just returns to C++ code:

        ret          // Returns to caller

这个.global asmMain 语句已经包含在每个拥有 asmMain 函数但没有定义或解释的程序中。现在是时候处理这个疏漏了。

Gas 源文件中的普通符号对该特定源文件是私有的,其他源文件无法访问(当然,除非这些源文件直接包含了包含这些私有符号的文件)。也就是说,大多数符号的作用域仅限于该特定源文件中的代码行以及它包含的任何文件。 .global 指令告诉 Gas 使指定符号对汇编单元全局,即在链接阶段,其他汇编单元也能访问这个符号。通过在本书中的示例程序中放置.global asmFunc 语句,这些示例程序使 asmMain 符号对包含它们的源文件全局化,以便c.cpp程序可以调用 asmMain 函数。

如您所记得,macOS 要求全局名称前面加上下划线前缀。这意味着,如果您希望该源文件在 macOS 下进行汇编,您应该使用.global _asmMain 和 _asmMain:。aoaa.inc头文件以可移植的方式解决了这个问题,但Listing1-3.S中的代码没有包含aoaa.inc

仅仅将符号设为公共是无法在另一个源文件中使用该符号的。想要使用该符号的源文件还必须声明该符号为外部。这会通知链接器,在外部声明的文件使用该符号时,它将需要修补公共符号的地址。例如,c.cpp源文件在以下代码行中将 asmMain 符号声明为外部(顺便提一下,这个声明也定义了外部符号 getTitle):

// extern "C" namespace prevents
// "name mangling" by the C++
// compiler.

extern "C"
{
    // Here's the external function,
    // written in assembly language,
    // that this program will call:

    void asmMain(void);
    int readLine(char *dest, int maxLen);
};

在这个例子中,readLine 实际上是一个在 c.cpp 源文件中定义的 C++ 函数。C/C++ 并没有明确的公共声明。相反,如果你在源文件中提供了一个函数的源代码,并声明该函数为外部符号,C/C++ 会通过外部声明自动将该符号设置为公共符号。

当你在程序中放置 .extern 指令时,Gas 会将该声明视为任何其他符号声明。如果该符号已存在,Gas 会生成符号重定义错误。通常,你应将所有外部声明放在源文件的开头,以避免作用域/前向引用问题。

从技术上讲,使用 .extern 指令是可选的,因为 Gas 假设你使用的任何符号如果在源文件中没有定义,都是外部符号。如果链接器在链接所有其他目标代码模块时未找到符号,它将报告实际的未定义符号。然而,显式地使用 .extern 定义外部符号是良好的编程风格,可以明确表达你的意图,方便其他人阅读你的源代码。

因为 .global 指令并不会真正定义符号,所以它的位置不像 .extern 指令那样重要。一些程序员将所有全局声明放在源文件的开头;也有一些程序员将全局声明放在符号定义之前(如我在大多数相同程序中对 asmMain 符号所做的那样)。这两种做法都可以。

因为一个源文件中的公共符号可以被多个汇编单元使用,所以会出现一个问题:你必须在所有使用该符号的文件中重复 .extern 指令。对于少数符号来说,这不是一个大问题。然而,随着外部符号数量的增加,在多个源文件中维护这些外部符号变得繁琐。

Gas 解决方案与 C/C++ 解决方案相同:头文件。这些文件只是包含外部(以及其他)声明的包含文件,这些声明在多个汇编单元中是共同的。头文件之所以得名,是因为包含它们的 include 语句通常出现在源文件的开头(“头”部分),用来将它们的代码注入到源文件中。这也恰好是 Gas 中包含文件的主要用途。

15.4 使用单独编译创建字符串库

第十四章提供了几个字符串处理函数的示例,附带宏和字符串结构体。这些函数和声明的问题在于,它们必须被剪切并粘贴到任何希望使用它们的源文件中。创建一个包含宏、结构体和外部符号定义的头文件,然后将各个函数编译成 .o 文件以便与那些希望使用这些函数的程序链接,显然是更好的选择。本节描述了如何为这些字符串函数创建可链接的目标模块。

字符串库的头文件是 strings.inc

// strings.inc
//
// String function header file for the assembly
// language string format

#ifndef  strings_inc
#define strings_inc 1

// Assembly language string data structure:

            struct  string, -16
            dword   string.allocPtr // At offset -16
            word    string.maxlen   // At offset -8
            word    string.len      // At offset -4
            byte    string.chars    // At offset 0

            // Note: characters in string occupy offsets
            // 0 ... in this structure.

            ends    string

// str.buf
//
// Allocate storage for an empty string
// with the specified maximum size:

            .macro  str.buf strName, maxSize
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   \maxSize
            .word   0
\strName:   .space  ((\maxSize+16) & 0xFFFFFFF0), 0
            .endm

// str.literal
//
// Allocate storage for a string buffer and initialize
// it with a string literal:

            .macro  str.literal strName, strChars
            .align  4   // Align on 16-byte boundary.
            .dword  0   // NULL ptr for allocation ptr
            .word   len_\strName    // string.maxlen
            .word   len_\strName    // string.len

            // Emit the string data and compute the
            // string's length:

\strName:   .ascii  "\strChars"
len_\strName=       .-\strName
            .byte   0   // Zero-terminating byte

 // Ensure object is multiple of 16 bytes:

            .align  4
            .endm

// str.len
//
//          Return the length of the string pointed at by X0.
//          Returns length in X0

            .macro  str.len
            ldr     w0, [x0, #string.len]
            .endm

// External declarations:

            .extern str.cpy
            .extern str.cmp
            .extern str.substr
            .extern str.bufInit
            .extern str.alloc
            .extern str.free

// This would be a good place to include external
// declarations for any string functions you write.

#endif

str.cpy 函数的源文件在 str.cpy.S 中:

// str.cpy.S
//
// A str.cpy string copy function

            #include    "aoaa.inc"
          ❶ #include    "strings.inc"

            .code

///////////////////////////////////////////////////////////
//
// str.cpy
//
// Copies the data from one string variable to another
//
// On entry:
//
// X0- Pointer to source string (string struct variable)
// X1- Pointer to destination string
//
// On exit:
//
// Carry flag clear if no errors, carry is set if
// the source string will not fit in the destination.

 ❷ proc    str.cpy, public

            locals  str_cpy
            qword   str_cpy.saveV0
            qword   str_cpy.saveX2X3
            dword   str_cpy.saveX4
            byte    str_cpy.stkSpace,64
            endl    str_cpy

            enter   str_cpy.size

            // Preserve X2 ... X4 and V0:

            str     q0,     [fp, #str_cpy.saveV0]
            stp     x2, x3, [fp, #str_cpy.saveX2X3]
            str     x4,     [fp, #str_cpy.saveX4]

            // Ensure the source will fit in the destination
            // string object:

            ldr     w4, [x0, #string.len]
            ldr     w3, [x1, #string.maxlen]
            cmp     w4, w3
            bhi     str.cpy.done    // Note: carry is set.

            // Set the length of the destination string
            // to the length of the source string.

            str     w4, [x1, #string.len]

            // X4 contains the number of characters to copy;
            // while this is greater than 16, copy 16 bytes
            // at a time from source to dest:

            mov     x2, x0  // Preserve X0 and X1.
            mov     x3, x1

cpy16:      ldr     q0, [x2], #16
            str     q0, [x3], #16
            subs    w4, w4, #16
            bhi     cpy16

// At this point, you have fewer than 16 bytes to copy. If
// W4 is not 0, just copy 16 remaining bytes (you know,
// because of the string data structure, that if you have at
// least 1 byte left to copy, you can safely copy
// 16 bytes):

            beq     setZByte    // Skip if 0 bytes.

            ldr     q0, [x2]
            str     q0, [x3]

// Need to add a zero-terminating byte to the end of
// the string. Note that maxlen does not include the
// 0 byte, so it's always safe to append the 0
// byte to the end of the string.

setZByte:   ldr     w4,  [x0, #string.len]
            strb    wzr, [x1, w4, uxtw]

            adds    wzr, wzr, wzr   // Clears the carry

str.cpy.done:
            ldr     q0,     [fp, #str_cpy.saveV0]
            ldp     x2, x3, [fp, #str_cpy.saveX2X3]
            ldr     x4,     [fp, #str_cpy.saveX4]
            leave
            endp    str.cpy

str.cpy.S 源文件是通过包含 strings.inc 头文件 ❶ 并从 第十四章第 819 页 的列表 14-2 中剪切粘贴 str.cpy 函数 ❷ 创建的。注意 proc 宏后的 public 参数。这会导致 proc 宏为 str.cpy 符号发出 .global 指令,使得该函数可以被其他源文件访问。

str.cmp.Sstr.substr.Sstr.alloc.Sstr.free.Sstr.bufInit.S 源文件是以类似方式从它们对应的函数(在 第十四章 中)创建的。我不会在这里包含这些源文件,因为它们是冗余的并且占用太多空间,但你可以在在线源文件中找到副本,网址是 <wbr>artofarm<wbr>.randallhyde<wbr>.com

如果你尝试使用常规构建命令来组装这些模块中的任何一个,系统会报错,提示缺少符号。这是因为这些模块不是独立的汇编语言程序。在接下来的部分,我会描述如何正确构建这些库模块;与此同时,这里有一些简单的命令可以在不出错的情况下组装这些文件(尽管会有警告):

./build -c str.cpy
./build -c str.cmp
./build -c str.substr
./build -c str.bufInit
./build -c str.alloc
./build -c str.free

这将组装文件而不运行链接器(-c 表示仅编译),分别生成文件 str.cpy.ostr.cmp.ostr.substr.ostr.bufInit.ostr.alloc.ostr.free.o。当然,下一个问题是如何将这些文件与应用程序链接。列表 15-1 是从 第十四章 中各种 asmMain 函数的合并版本,它们调用了 str.cpy、str.cmp 和 str.substr 函数。

// Listing15-1.S
//
// A program that calls various string functions

            #include    "aoaa.inc"
 #include    "strings.inc"

///////////////////////////////////////////////////////////

            .data

            str.buf     destination, 256
            str.literal src,    "String to copy"
            str.literal left,   "some string"
            str.literal right1, "some string"
            str.literal right2, "some string."
            str.literal right3, "some string"

            str.buf     smallDest, 32
            str.literal dest,   "Initial destination string"

//                             1111111111222222222233333
//                   01234567890123456789012345678901234
str.literal source, "Hello there, world! How's it going?"

fmtStr:     .asciz      "source='%s', destination='%s'\n"
ltFmtStr:   .asciz      "Left ('%s') is less than right ('%s')\n"
gtFmtStr:   .asciz      "Left ('%s') is greater than right ('%s')\n"
eqFmtStr:   .asciz      "Left ('%s') is equal to right ('%s')\n"

successStr: .asciz      "substr('%s', %2d, %3d)= '%s'\n"
failureStr: .asciz      "substr('%s', %2d, %3d) failed\n"

///////////////////////////////////////////////////////////

            .code
ttlStr:     wastr  "Listing15-1"

// Standard getTitle function
// Returns pointer to program name in X0

            proc    getTitle, public
            lea     x0, ttlStr
            ret
            endp    getTitle

///////////////////////////////////////////////////////////
//
// prtResult
//
// Utility function to print the result of a string
// comparison:

            proc    prtResult

            mov     x2, x1
            mov     x1, x0
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
 beq     strsEQ
            bhi     strGT

            // Must be LT at this point.

            lea     x0, ltFmtStr
            b       printf

strsEQ:     lea     x0, eqFmtStr
            b       printf

strGT:      lea     x0, gtFmtStr
            b       printf

            endp    prtResult

///////////////////////////////////////////////////////////
//
// testSubstr
//
// Utility function to test call to str.substr
//
// On entry:
// X0, X1, X2, X3 -- str.substr parameters

            proc    testSubstr

            locals  testSS
            byte    testSS.stkspace, 64
            endl    testSS

            enter   testSS.size

            lea     x5, successStr
            bl      str.substr
            bcc     success
            lea     x5, failureStr

success:
            mov     x4, x3
            mov     x3, x2
            mov     x2, x1
            mov     x1, x0
            mov     x0, x5
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            mstr    x3, [sp, #16]
            mstr    x4, [sp, #24]
            bl      printf
            leave
            endp    testSubstr

///////////////////////////////////////////////////////////
//
// Main program to test the code:

            proc    asmMain, public

            locals  lcl
            byte    stkSpace, 64
            endl    lcl

            enter   lcl.size      // Reserve space for locals.

            lea     x0, src
            lea     x1, destination
            bl      str.cpy

            mov     x2, x1
            mov     x1, x0
            lea     x0, fmtStr
            mstr    x1, [sp]
            mstr    x2, [sp, #8]
            bl      printf

            lea     x0, left
            lea     x1, right1
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right2
            bl      str.cmp
            bl      prtResult

            lea     x0, left
            lea     x1, right3
            bl      str.cmp
            bl      prtResult

            lea     x0, source
            mov     x1, #0
            mov     x2, #11
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #20
            mov     x2, #15
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #20
            mov     x2, #20
 lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #40
            mov     x2, #20
            lea     x3, dest
            bl      testSubstr

            lea     x0, source
            mov     x1, #0
            mov     x2, #100
            lea     x3, smallDest
            bl      testSubstr

AllDone:    leave
            endp    asmMain

如果你尝试使用以下命令来构建这个程序

./build Listing15-1

系统会抱怨它无法在提供的目标文件中找到符号 str.cpy、str.cmp 和 str.substr。不幸的是,build shell 脚本不支持在多个目标模块之间链接(除了 c.cpp 和指定文件的目标文件)。因此,你必须指定一个明确的 g++ 命令来处理所有文件:

g++ -DisMacOS c.cpp Listing15-1.S str.cpy.o str.cmp.o str.substr.o -o Listing15-1

在 Linux 或 Pi OS(而不是 macOS)下编译代码时,命令行参数 -DisMacOS 应该更改为 -DisLinux。正如你在第 1.10.1 节“在多个操作系统下汇编程序”中从 第 36 页 回忆的那样,build shell 脚本会确定操作系统并发出一个 g++ 命令行定义(-Dxxxx 选项),以使操作系统对汇编源文件(特别是 aoaa.inc 头文件)可知。由于这个 g++ 命令会尝试汇编 Listing15-1.S 源文件(其中包含 aoaa.inc),因此命令行必须包含 isMacOS 或 isLinux 的定义,否则汇编将失败。

这个 g++ 命令将编译 c.cpp,汇编 Listing15-1.S,并将它们的目标文件与 str.cpy.ostr.cmp.ostr.substr.o 目标文件链接在一起。当然,这假设你已经汇编了 str..S* 源文件,并且它们的目标文件位于当前目录。Listing 15-1 中的示例程序并没有调用 str.alloc、str.free 或 str.bufInit 函数,因此无需将它们各自的目标代码文件链接进来,尽管这么做不会产生错误。

这是构建所有这些文件并生成和运行 Listing 15-1 可执行文件所需的完整命令集:

% g++ -c -DisMacOS str.cpy.S
% g++ -c -DisMacOS str.cmp.S
% g++ -c -DisMacOS str.substr.S
% g++ -DisMacOS c.cpp Listing15-1.S str.cpy.o str.cmp.o str.substr.o -o Listing15-1
% ./Listing15-1
Calling Listing15-1:
source='String to copy', destination='String to copy'
Left ('some string') is equal to right ('some string')
Left ('some string') is less than right ('some string.')
Left ('some string') is greater than right ('some string')
substr('Hello there, world! How's it going?',  0,  11)= 'Hello there'
substr('Hello there, world! How's it going?', 20,  15)= 'How's it going?'
substr('Hello there, world! How's it going?', 20,  20)= 'How's it going?'
substr('Hello there, world! How's it going?', 40,  20)= ''
substr('Hello there, world! How's it going?',  0, 100) failed
listing15-1 terminated

诚然,为了编译和链接一个简单的源文件,这需要大量的输入。你可以通过将所有命令放入一个文本文件并作为 shell 脚本执行(类似于build脚本)来解决这个问题,但有一个更好的方法:makefile。

15.5 引入 Makefile

本书中使用的build文件比手动命令要方便得多,用于构建上一节中的示例。不幸的是,build 支持的构建机制仅适用于少数固定的源文件。虽然你可以轻松地构建一个 shell 脚本来编译大型汇编项目中的所有文件,但这在很大程度上违背了使用单独汇编文件的目的,因为运行脚本文件会重新汇编项目中的每个源文件。虽然你可以使用复杂的命令行功能来避免部分问题,但使用 makefile 会更加简便。

makefile 是一种脚本语言(最早在 Unix 发布时设计)用于指定如何基于某些条件执行一系列命令。最简单的形式下,makefile 可以完全像 shell 脚本一样运行;你可以在文本文件中列出一系列命令,并让 make 程序执行它们。当然,如果你仅仅这样做,那么使用 shell 脚本并不会带来额外的好处;如果你打算使用 makefile,应该利用 make 的特性。

make 程序是一个可执行文件,就像 Gas (as) 或 GCC 一样。由于 make 并不是 Linux 或 macOS 系统的一部分,因此在使用之前,你必须先获取 make 程序。幸运的是,大多数 Linux 和 macOS 发行版都预装了 make(如果你能运行 GCC,当然也能运行 make)。你可以通过命令行执行它,像这样:

make `optionalArguments`

如果你在命令行执行 make 时没有任何参数,make 会查找一个名为 Makefile 的文件,并尝试处理该文件中的命令。对于许多项目来说,这非常方便。如果你将所有源文件放在一个目录中(可能包含子目录),并将一个名为 Makefile 的单一 makefile 放在其中,那么你只需进入该目录并执行 make,就可以在最小的麻烦下构建项目。

如果你愿意,可以使用与Makefile不同的文件名。不过,必须注意的是,使用命令行时,你必须在文件名之前加上 make -f 选项,像这样:

make -f mymake.mak

你不需要给文件名加上 .mak 扩展名,但这是使用自定义名称的 makefile 时的常见约定。

make 程序提供了许多命令行选项,你可以使用 --help 列出常用选项。你可以在线查阅 make 文档(或在命令行中输入 man make)以了解其他命令行选项的说明,但它们大多数是高级选项,通常在大多数任务中不需要。

当然,要实际使用 make,你需要创建 makefile。以下小节描述了 make 脚本语言及一些常见的 makefile 约定。

15.5.1 基本 Makefile 语法

makefile 是一个标准的 ASCII 文本文件,包含如下格式的多行(或多次出现此序列):

`target`: `dependencies`
    `commands`

代码中的所有组件——目标、依赖项和命令——都是可选的。目标项是某种标识符或文件名,如果存在,必须从源行的第 1 列开始。依赖项项是目标正确构建所依赖的文件名列表。命令项是一个或多个命令行命令的列表,命令前必须至少有一个制表符。

考虑以下 makefile,它构建了一组字符串库函数(注意,每个 g++ 命令前都有一个制表符):

all:
    g++ -c -DisMacOS str.cpy.S
    g++ -c -DisMacOS str.cmp.S
    g++ -c -DisMacOS str.substr.S
    g++ -DisMacOS c.cpp Listing15-1.S str.cpy.o str.cmp.o \
         str.substr.o -o Listing15-1

如果这些命令出现在一个名为 Makefile 的文件中,并且你执行 make,它们将像命令行解释器那样执行,假如它们出现在 shell 脚本中。

考虑对前一个 makefile 的以下修改:

executable:
  g++ -c -DisMacOS Listing15-1.S
  g++ -DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

library:
  g++ -c -DisMacOS str.cpy.S
  g++ -c -DisMacOS str.cmp.S
  g++ -c -DisMacOS str.substr.S

这将构建命令分成两组:一组由可执行文件标签指定,另一组由库标签指定。

如果你运行 make 而不指定任何命令行选项,它只会执行文件中第一个目标后面的命令。因此,在这个例子中,如果你单独运行 make,它将汇编 Listing15-1.S,编译 c.cpp,并尝试将(生成的)c.objstr.cpy.ostr.cmp.ostr.substr.oListing15-1.o 链接在一起。假设你之前已经编译过字符串函数,这应该能够成功生成 Listing15-1 可执行文件(无需重新编译字符串函数)。

为了让 make 处理库目标之后的命令,你必须将目标名称作为 make 命令行参数指定:

make library

这个 make 命令编译 str.cpy.Sstr.cmp.Sstr.substr.S。如果你执行一次这个命令(并且此后不再更改字符串函数),只需单独执行 make 命令即可生成可执行文件。如果你想明确表示自己在构建可执行文件,也可以使用 make executable。

在命令行上指定你想要构建的目标是非常有用的。然而,随着项目变大,源文件和库模块增多,时刻跟踪哪些源文件需要重新编译可能会变得繁琐且容易出错。如果你不小心,在修改了某个不常见的库模块之后,可能会忘记重新编译它,然后不明白为什么应用程序仍然失败。make 的依赖选项通过让你自动化构建过程,帮助你避免这些问题。

在 makefile 中,目标后可以跟一个或多个由空格分隔的依赖关系:

target: `dependency1` `dependency2` `dependency3` ...

依赖关系可以是目标名称(出现在该 makefile 中的目标)或文件名。如果依赖关系是目标名称(且不是文件名),make 将执行与该目标相关的命令。考虑以下 makefile(如果在 Linux 或 Pi OS 下编译,请确保将这个例子中的-DisMacOS 命令行选项更改为-DisLinux):

executable:
  g++-c -DisMacOS Listing15-1.S
  g++-DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

library:
  g++ -c -DisMacOS str.cpy.S
  g++-c -DisMacOS str.cmp.S
  g++-c -DisMacOS str.substr.S

all: library executable

这个代码中的 all 目标没有任何与之相关的命令。相反,all 目标依赖于库和可执行目标,因此它将执行与这些目标相关的命令,从库开始。这是因为在将关联的对象模块链接成可执行程序之前,必须先构建库的目标文件。all 标识符是 makefile 中常见的目标。事实上,它通常是 makefile 中出现的第一个或第二个目标。

如果一个目标:依赖关系行变得过长,导致难以阅读(make 不太关心行的长度),你可以通过在行的末尾加上反斜杠字符(\)来将该行分为多行。make 程序会将以反斜杠结尾的源行与 makefile 中的下一行合并。反斜杠必须是行的最后一个字符;行尾不允许有空白字符(制表符和空格)。

目标名称和依赖关系也可以是文件名。将文件名指定为目标名称通常是为了告诉 make 系统如何构建该特定文件。例如,你可以将当前的示例重写如下:

executable:
  g++ -c -DisMacOS Listing15-1.1
  g++ -DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

library: str.cpy.o str.cmp.o str.substr.o

str.cpy.o:
  g++ -c -DisMacOS str.cpy.S

str.cmp.o:
  g++ -c -DisMacOS str.cmp.S

str.substr.o:
  g++ -c -DisMacOS str.substr.S

all: library executable

当依赖关系与一个文件名目标相关联时,你可以将目标:依赖关系语句理解为“目标依赖于依赖关系”。在处理命令时,make 会比较作为目标和依赖关系文件名指定的文件的修改日期/时间戳。

如果目标的日期/时间比任何依赖项都要旧(或者目标文件不存在),make 将执行目标后面的命令。如果目标文件的修改日期/时间比所有依赖文件都要新,make 将不会执行命令。如果目标后的某个依赖项本身是其他地方的目标,make 将首先执行那个命令(以查看它是否修改了目标对象,改变其修改日期/时间,可能导致 make 执行当前目标的命令)。如果目标或依赖项只是一个标签(而不是文件名),make 将把它的修改日期/时间视为比任何文件都旧。

考虑以下对正在运行的 makefile 示例的修改:

Listing15-1:Listing15-1.o str.cpy.o str.cmp.o str.substr.o
  gcc -DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

Listing15-1.o:
  g++ -c -DisMacOS Listing15-1.S

str.cpy.o:
  g++ -c -DisMacOS str.cpy.S

str.cmp.o:
  g++ -c -DisMacOS str.cmp.S

str.substr.o:
  g++ -c -DisMacOS str.substr.S

这段代码删除了所有和库的目标,因为它们被证明是不必要的,并将可执行文件更改为 Listing15-1,即最终的目标可执行文件。

因为 str.cpy.o、str.cmp.o、str.substr.o 和 Listing15-1.o 都是目标(以及文件名),make 将首先处理这些目标。之后,make 会将 Listing15-1 的修改日期/时间与这四个目标文件的修改日期/时间进行比较。如果 Listing15-1 比任何一个目标文件旧,make 将执行 Listing15-1 目标行之后的命令(编译c.cpp并将其与目标文件链接)。如果 Listing15-1 比其依赖的目标文件新,make 将不执行该命令。

对于所有依赖的目标文件,在处理 Listing15-1 目标时,同样的过程会递归发生。处理 Listing15-1 目标时,make 还会处理 str.cpy.o、str.cmp.o、str.substr.o 和 Listing15-1.o 目标(按此顺序)。在每种情况下,make 会将.o文件的修改日期/时间与相应的.S文件进行比较。如果.o文件的修改日期/时间比.S文件新,make 将返回并继续处理 Listing15-1 目标,而不执行任何操作;如果.o文件比.S文件旧(或不存在),make 将执行相应的 g++命令生成新的.o文件。

如果 Listing15-1 比所有.o文件更新(并且它们都比.S文件更新),那么执行 make 时只会报告 Listing15-1 是最新的,但不会执行 makefile 中的任何命令。如果任何文件是过时的(因为它们已被修改),这个 makefile 将只编译和链接必要的文件,以使 Listing15-1 保持最新。

到目前为止,makefile 有一个相当严重的缺陷:缺少一个重要的依赖项。由于所有的.S文件都包含aoaa.inc文件,因此aoaa.inc的更改可能会要求重新编译这些.S文件。Listing 15-2 在Listing15-2.mak makefile 中添加了这个依赖,并演示了如何在 makefile 中通过在行首使用#字符来添加注释。

# Listing15-2.mak
#
# makefile for Listing15-1

Listing15-1:Listing15-1.o str.cpy.o str.cmp.o str.substr.o
  gcc -DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

Listing15-1.o:aoaa.inc Listing15-1.S
  gcc -c -DisMacOS Listing15-1.S

str.cpy.o:aoaa.inc str.cpy.S
  gcc -c -DisMacOS str.cpy.S

str.cmp.o:aoaa.inc str.cmp.S
  gcc -c -DisMacOS str.cmp.S

str.substr.o:aoaa.inc str.substr.S
  gcc -c -DisMacOS str.substr.S

这是执行 make(在 macOS 下)的一个示例:

% make -f Listing15-2.mak
gcc -c -DisMacOS Listing15-1.S
gcc -c -DisMacOS str.cpy.S
gcc -c -DisMacOS str.cmp.S
gcc -c -DisMacOS str.substr.S
gcc -DisMacOS c.cpp Listing15-1.o str.cpy.o str.cmp.o str.substr.o -o Listing15-1

在 Linux 或 Pi OS 下执行此命令时,请不要忘记将所有 -DisMacOS 命令行选项改为 -DisLinux,并确保所有命令在第一列有一个制表符。如果希望能够自动为任何操作系统编译代码,只需复制 构建 脚本中的代码,该脚本设置了带有适当命令行选项的 shell 变量,如列表 15-3 所示。

# Listing15-3.mak
#
# makefile for Listing15-1 with dependencies that will
# automatically set up the define for the OS

❶ unamestr=`uname`

Listing15-1:Listing15-1.o str.cpy.o str.cmp.o str.substr.o
    gcc -D$(unamestr) c.cpp Listing15-1.o str.cpy.o str.cmp.o \
        str.substr.o -o Listing15-1

Listing15-1.o:aoaa.inc Listing15-1.S
  ❷ gcc -c -D$(unamestr) Listing15-1.S

str.cpy.o:aoaa.inc str.cpy.S
    gcc -c -D$(unamestr) str.cpy.S

str.cmp.o:aoaa.inc str.cmp.S
    gcc -c -D$(unamestr) str.cmp.S

str.substr.o:aoaa.inc str.substr.S
    gcc -c -D$(unamestr) str.substr.S

第一个语句 ❶ 是一个 makefile (或 变量)的示例。OS 命令 uname 将显示操作系统(内核)名称。在 Linux 系统下,这将被替换为字符串 Linux,而在 macOS 系统下则为字符串 Darwin(macOS 内核的内部名称)。

Makefile 宏使用延迟执行。这意味着宏 unamestr 实际上包含了文本 uname,而 uname 命令将在 make 程序展开 unamestr 宏时执行。make 程序将展开 -D$(unamestr) 命令行选项,生成 -D uname ❷。反引号(`)告诉 make 执行该命令并用命令打印的文本替换它:操作系统内核名称。

唯一的问题是 uname 命令会打印出 Linux 或 Darwin,因此 -D 命令定义了这两个符号之一。构建 脚本将这些字符串转换为 isMacOSisLinux。我最初这样做是因为在基于 Linux 的汇编语言程序中,符号 Linux 可能会出现。不幸的是,这种符号翻译技巧在 makefile 中无法生效,因此我修改了 aoaa.inc,使其既接受 LinuxDarwin,也接受 inLinuxinMacOS。我修改了 aoaa.inc 来进行翻译,并在使用这些符号时取消定义 LinuxDarwin

// Makefiles define the symbols Darwin (for macOS)
// and Linux (for Linux) rather than isMacOS and
// isLinux. Deal with that here:

#ifdef Darwin
    #define isMacOS (1)
    #undef isLinux
    #undef Darwin
#endif
#ifdef Linux
    #define isLinux (1)
    #undef isMacOS
    #undef Linux
#endif

这是执行 make 命令以构建列表 15-3 中代码的过程(假设没有已经创建的目标文件):

% make -f Listing15-3.mak
g++ -c -D`uname` Listing15-1.S
g++ -c -D`uname` str.cpy.S
g++ -c -D`uname` str.cmp.S
g++ -c -D`uname` str.substr.S
g++ -D`uname` c.cpp Listing15-1.o str.cpy.o str.cmp.o \
    str.substr.o -o Listing15-1

注意,-D uname 会根据操作系统的不同翻译为 -DLinux-DDarwin

15.5.2 Make Clean 和 Touch

在大多数专业制作的 makefile 中,你会发现一个常见的目标是 clean,它删除一组适当的文件,以便下次执行 makefile 时强制重新构建整个系统。该命令通常会删除与项目相关的所有 *.o 文件和可执行文件。

列表 15-4 提供了一个针对列表 15-3 中 makefile 的示例清理目标。

# Listing15-4.mak
#
# makefile for listing15-1 with dependencies that will
# automatically set up the define for the OS
#
# Demonstrates the clean target

unamestr=`uname`

Listing15-1:Listing15-1.o str.cpy.o str.cmp.o str.substr.o
    gcc -D$(unamestr) c.cpp listing15-1.o str.cpy.o str.cmp.o \
        str.substr.o -o Listing15-1

Listing15-1.o:aoaa.inc Listing15-1.S
    gcc -c -D$(unamestr) Listing15-1.S
str.cpy.o:aoaa.inc str.cpy.S
    gcc -c -D$(unamestr) str.cpy.S

str.cmp.o:aoaa.inc str.cmp.S
    gcc -c -D$(unamestr) str.cmp.S

str.substr.o:aoaa.inc str.substr.S
    gcc -c -D$(unamestr) str.substr.S
clean:
    rm str.cpy.o
    rm str.cmp.o
    rm str.substr.o
    rm Listing15-1.o
    rm c.o
    rm Listing15-1

执行命令

% make -f Listing15-4.mak clean

将删除与项目相关的所有可执行文件和目标文件。

要强制重新编译单个文件(而不手动编辑和修改它),可以使用 Unix 工具 touch。该程序接受一个文件名作为参数,并更新文件的修改日期/时间(不会修改文件内容)。例如,在使用列表 15-4 中的 makefile 构建 Listing15-1.S 后,如果执行以下命令

touch Listing15-1.S

然后重新执行列表 15-4 中的 makefile,make 将重新汇编 Listing15-1.S 中的代码,重新编译 c.cpp,并生成一个新的可执行文件。

15.6 使用归档程序生成库文件

许多常见的项目重用开发者很久以前创建的代码,或者来自开发者组织外部的代码。这些代码库相对静态:在使用它们的项目开发过程中,它们很少发生变化。特别地,通常不会将库的构建包含在某个特定项目的 makefile 中。一个特定的项目可能会在 makefile 中列出库文件作为依赖项,但假设这些库文件已经在其他地方构建,并作为整体提供给项目。

更重要的是,库与一组目标代码文件之间有一个主要的区别:打包。当你在处理大量独立的目标文件时,尤其是当你需要处理大量的库目标文件时,事情变得麻烦。一个库可能包含几十、几百甚至几千个目标文件。列出所有这些目标文件(或者仅列出一个项目使用的文件)是一个繁琐的工作,可能会导致一致性错误。

解决这个问题的常见方法是将目标文件组合成一个单独的包(文件),称为库文件。在 Linux 和 macOS 下,库文件通常有一个.a后缀(其中a代表归档)。对于许多项目,你会得到一个库文件,它将特定的库模块打包在一起。你将这个文件提供给链接器,当构建程序时,链接器会自动从库中提取它需要的目标模块。这是一个重要的点:在构建可执行文件时包含一个库并不会自动将库中的所有代码插入到可执行文件中。链接器足够智能,能够仅提取它需要的目标文件,忽略它不使用的目标文件(记住,库只是一个包含大量目标文件的包)。

如何创建一个库文件?简短的回答是:“通过使用归档程序(ar)。”以下是它的基本语法

ar rcs `libname.a` `list-of-.o-files`

其中,libname.a 是你想要生成的库文件的名称,而 list-of-.o-files 是你想要收集到库中的目标文件名称列表(以空格分隔)。例如,以下命令将 print.ogetTitle.o 文件合并成一个库模块(aoaalib.a):

ar rcs aoaalib.a getTitle.o print.o

rcs 组件实际上是一系列三个命令选项。r 选项告诉命令替换归档中已有的(如果有)目标文件;c 选项表示创建归档(如果你是将目标文件添加到现有归档文件中,通常不需要指定此选项);s 选项表示为归档文件添加索引,或者如果索引已存在,则更新索引。(有关更多 ar 命令行选项,请参阅第 15.9 节“更多信息”,见第 887 页。)

一旦你有了一个库模块,你可以像指定目标文件一样在链接器(或 ld 或 gcc)的命令行中指定它。例如,如果你构建了一个strings.a库模块来保存str.cpy.ostr.cmp.ostr.substr.ostr.bufInit.ostr.free.ostr.alloc.o目标文件,并且你想要将strings.a与 Listing 15-1 中的程序链接,你可以使用以下命令:

g++ -DisMacOS c.cpp Listing15-1.S strings.a -o Listing15-1

Listing 15-5 是一个 makefile 的示例,它将构建strings.a库文件。

# Listing15-5.mak
#
# makefile to build the string.a library file

unamestr=`uname`

strings.a:str.cpy.o str.cmp.o str.substr.o str.bufInit.o \
            str.alloc.o str.free.o
    ar rcs strings.a str.cpy.o str.cmp.o str.substr.o \
        str.bufInit.o str.alloc.o str.free.o

str.cpy.o:aoaa.inc str.cpy.S
    g++ -c -D$(unamestr) str.cpy.S

str.cmp.o:aoaa.inc str.cmp.S
    g++ -c -D$(unamestr) str.cmp.S

str.substr.o:aoaa.inc str.substr.S
    g++ -c -D$(unamestr) str.substr.S

str.bufInit.o:aoaa.inc str.bufInit.S
    g++ -c -D$(unamestr) str.bufInit.S

str.free.o:aoaa.inc str.free.S
    g++ -c -D$(unamestr) str.free.S

str.alloc.o:aoaa.inc str.alloc.S
    g++ -c -D$(unamestr) str.alloc.S

 clean:
    rm -f strings.a
    rm -f str.cpy.o
    rm -f str.cmp.o
    rm -f str.substr.o
    rm -f str.bufInit.o
    rm -f str.alloc.o
    rm -f str.free.o

Listing 15-6 修改了 Listing 15-5 的 makefile,通过使用strings.a库模块来构建代码。

# Listing15-6.mak
#
# makefile that uses the string.a library file

unamestr=`uname`

Listing15-1:Listing15-1.o strings.a
    g++ -D$(unamestr) c.cpp Listing15-1.o strings.a -o Listing15-1

Listing15-1.o:aoaa.inc Listing15-1.S
    g++ -c -D$(unamestr) Listing15-1.S

lib:
    rm -f strings.a
    rm -f str.*.o
    make -f Listing15-5.mak

clean:
    rm -f Listing15-1
    rm -f c.o
    rm -f Listing15-1.o

请注意,clean 命令不会删除库文件。如果你想进行干净的库构建,只需在运行 make 时指定 lib 命令行选项:

make -f Listing15-6.mak lib

一般来说,你应该独立构建库代码和应用程序代码。大多数时候,库是预构建的,你不需要重新构建它。然而,strings.a必须是应用程序的依赖项,因为如果库发生变化,你很可能也需要重新构建应用程序。

另一个在处理库文件时有用的 Unix 工具是 nm(names)。nm 工具会列出库模块中找到的所有全局名称。例如,命令

nm strings.a

列出了在strings.a库文件中找到的所有(全局)符号(它比较长,我这里不提供打印输出)。

15.7 管理目标文件对程序大小的影响

程序中的链接基本单元是目标文件。当将目标文件组合成可执行文件时,链接器会将单个目标文件中的所有数据合并到最终的可执行文件中。即使主程序没有调用目标模块中的所有函数(直接或间接)或没有使用该目标文件中的所有数据,这一点仍然成立。如果你把 100 个例程放入一个汇编语言源文件中,并将其编译成目标模块,那么链接器将在最终的可执行文件中包含所有 100 个例程的代码,即使你只使用其中的一个。

为了避免这种情况,你可以将这 100 个例程拆分成 100 个单独的目标模块,并将生成的 100 个目标文件合并成一个库文件。当链接器处理这个库文件时,它会挑选出包含程序使用的函数的单个目标文件,并只将该文件合并到最终的可执行文件中。

通常,这比将一个包含 100 个函数的单一目标文件链接到程序要高效得多。然而,在某些情况下,将多个函数合并到一个目标文件中是有充分理由的。首先,考虑一下链接器将目标文件合并到可执行文件时会发生什么。为了确保正确的对齐,每当链接器从目标文件中提取一个段/区段(例如 .code 区段)时,它会添加足够的填充,以确保该区段中的数据按照该区段指定的对齐边界进行对齐。大多数区段有一个默认的 16 字节区段对齐。这意味着链接器将把从目标文件中链接的每个区段对齐到 16 字节边界上。

通常,这并不是一个大问题,特别是当你的过程很大时。然而,如果这 100 个过程都非常简短(每个只有几个字节),你就会浪费大量空间。确实,在现代计算机上,几百字节的浪费空间并不是大问题。不过,将其中一些过程合并成一个目标模块(即使你并不调用所有这些过程)来填补一些浪费的空间,可能会更实用。寻找自然配对的元素或有依赖关系的元素,例如 alloc 和 free。然而,别做得过头。一旦你超过了对齐边界,无论是因为填充浪费空间,还是因为你包含了永远不会被调用的代码,你都会浪费空间。

15.8 接下来的内容

如果你用汇编语言编写大型应用程序,你将需要将源代码分解成多个模块,并自动化从这些模块构建应用程序。本章首先讨论了 Gas 在模块之间共享外部和公共符号的机制。接着介绍了用于从多个源文件构建应用程序的 make 工具,然后讲解了如何通过链接器和归档工具构建库模块。

一个大型库代码的来源是操作系统内核(如 macOS、Linux 或 Pi OS)。然而,不要将操作系统库函数链接到你的应用程序中;当应用程序运行时,这些代码已经存在于内存中。调用操作系统函数时,你需要使用操作系统 API 调用序列。下一章将讨论如何在 Linux(Pi OS)和 macOS(Darwin)内核中调用操作系统函数。

15.9 更多信息

第十六章:16 独立汇编语言程序

到目前为止,本书依赖于一个 C/C++ 主程序来调用用汇编语言编写的示例代码。虽然这可能是现实世界中使用汇编语言的最大案例,但也可以编写独立的汇编程序(不需要 C/C++ 主程序)。在本章中,你将学习如何编写这样的独立程序。

就本书而言,独立汇编语言程序指的是汇编语言代码包含一个实际的 main 程序(而不是 asmMain,它只是 C++ 程序调用的一个函数)。这样的程序不进行任何 C/C++ stdlib 调用;唯一的外部调用是操作系统 API 函数调用。

注意

一些读者可能会将“独立程序”一词理解为汇编语言程序不进行任何外部函数调用,甚至不调用操作系统,而是在应用程序内部以硬件级别处理所有 I/O。这个定义适用于嵌入式系统,但不是本书中的定义。

从技术上讲,你的汇编代码总是会被 C/C++ 程序调用。这是因为操作系统本身是用 C/C++ 编写的,只有极少量的汇编代码。当操作系统将控制权转交给你的汇编代码时,这与 C/C++ 主程序调用你的汇编代码没有太大区别。然而,“纯”汇编应用程序有一些明显的优势:你不需要携带 C/C++ 库代码和应用程序运行时系统,因此你的程序可以更小,而且不会与 C/C++ 公共名称发生外部命名冲突。

本章介绍了适用于 macOS 和 Linux(包括 Pi OS)的操作系统系统调用。它首先解释了如何在代码中保持可移植性,因为系统调用在不同操作系统之间并不具有可移植性。接下来介绍了这两个操作系统的系统调用概念。在讨论了用于调用操作系统 API 函数的 svc(超级调用)指令之后,提供了两个示例:一个独立的“Hello, world!”应用程序和一个文件 I/O 应用程序。最后指出,macOS 不鼓励直接进行系统调用,而是希望你通过 C 库函数调用与操作系统进行交互。

16.1 系统调用的可移植性问题

尽管到目前为止,本书中的大多数示例程序在 macOS 和 Linux 之间是可移植的,但系统 API 调用因操作系统而异。前几章中的代码通过调用 C/C++ stdlib 函数来处理操作系统的低级细节,忽略了这个问题,但本章中的示例代码则进行了操作系统特定的调用。因此,可移植性不会自动发生。你有四种方法来处理这个问题:

  • 忽略可移植性,仅为 macOS 或 Linux 编写给定的示例程序。通常,在编写特定于操作系统的代码时,我会采用这种方法。

  • 编写两个(不可移植的)版本的相同程序:一个适用于 Linux,一个适用于 macOS。

  • 编写一个单一的程序,使用条件汇编根据需要包含操作系统特定的代码。

  • 创建两个封装文件,一个包含 macOS 版本的操作系统调用,另一个包含 Linux 版本的操作系统调用,并将适当的封装文件与主(便携式)代码一起包含。

使用哪种机制取决于你的应用。如果你不打算编写能够跨操作系统工作的便携式汇编代码(编写汇编应用时最常见的情况),你将采用第一种方法,仅为你所针对的操作系统编写代码。

如果你希望你的汇编应用在 macOS 和 Linux 上运行,你的选择将取决于应用的规模。如果应用相对较小,编写两个操作系统特定的变种并不难(尽管维护可能是一个问题,因为你需要维护两个独立版本的应用)。如果应用程序较大,或者你预计会频繁升级和维护它,第三种或第四种方法可能更好。使用条件汇编来处理操作系统特定问题的单一应用程序通常比两个独立的应用程序更容易维护和扩展,而使用封装代码使得维护每个特定操作系统的代码更加容易。

还有第五种方法:将所有操作系统相关的代码用 C/C++编写,并调用处理与操作系统无关的功能的汇编函数。这就是本书中所有示例程序的编写方式。

不言而喻,本章中的代码没有使用build脚本来编译/汇编示例应用程序。build脚本假设使用c.cpp主程序(而本章的重点就是停止使用该代码)。因此,本章中的每个示例程序都包含一个 makefile,用于构建代码。

16.2 独立代码与系统调用

本书中的第一个示例程序是第 5 页上的清单 1-1,它是一个独立程序。为了讨论的需要,下面是经过几处修改后的清单 16-1。

// Listing16-1.S
//
// Comments consist of all text from a //
// sequence to the end of the line.
// The .text directive tells MASM that the
// statements following this directive go in
// the section of memory reserved for machine
// instructions (code).

       .text

// Here is the main function.
// (This example assumes that the
// assembly language program is a
// stand-alone program with its own
// main function.)
//
// Under macOS, the main program
// must have the name _main
// beginning with an underscore.
// Linux systems generally don't
// require the underscore.
//
// The .global _main statement
// makes the _main procedure's name
// visible outside this source file
// (needed by the linker to produce
// an executable).

 ❶ .global _main  // This is the macOS entry point.
     ❷ .global main   // This is the Linux entry point name.

// The .align 2 statement tells the
// assembler to align the following code
// on a 4-byte boundary (required by the
// ARM CPU). The 2 operand specifies
// 2 raised to this power (2), which
// is 4.

       .align 2

// Here's the actual main program. It
// consists of a single ret (return)
// instruction that simply returns
// control to the operating system.

❶ _main:
❷ main:
    ❸ ret

与清单 1-1 中的代码相比,我对这段代码进行了两处修改。在❶和❷的位置,我引入了一个新符号,main(和 _main)。这是因为 Linux 要求主程序命名为 main,而 macOS 要求命名为 _main。如果你尝试在 Linux 上编译清单 1-1,你会得到类似“未定义的main引用”这样的错误消息。我干脆在源文件中同时包含这两个符号,而不是处理条件汇编(或编写两个独立的清单 16-1 版本)。Linux 大多忽略 _main 符号,macOS 忽略 main 符号;因此,该程序能够在任一操作系统上顺利编译。

清单 16-1 由一条指令组成:ret ❸。进入时,LR 寄存器包含一个返回地址,将控制转回操作系统。因此,这个程序(如果你真的执行它)会立即返回到操作系统。

尽管通过 ret 指令返回操作系统有效(特别是在使用 GCC 构建此代码时),但这不是返回 Linux 或 macOS 的标准方式。相反,应用程序应该调用 exit() API 函数。要调用系统 API 函数,程序必须将函数号加载到寄存器中,将适当的参数加载到参数寄存器(X0 至 X7)中,然后执行超级用户(操作系统)调用指令 svc #OSint,其中 OSint 对于 Linux 是 0,对于 macOS 是 0x80。

注意

实际上,macOS 似乎忽略了 svc 指令后面的立即数常量。许多在线示例使用值 0 作为 svc 操作数(个人实验也证明它有效)。然而,macOS 源代码似乎使用 0x80 作为常量,因此我建议在 macOS 下使用该值。

在 Linux 中,您将系统调用号加载到 X8 寄存器中,而在 macOS 中,您将其加载到 X16 寄存器中。我在 aoaa.inc 中添加了以下语句来处理此问题:

#if isMacOS

    // Under macOS, the system call number
    // goes into X16:

    #define svcReg x16
    #define OSint  0x80

#else

    // Under Linux, the system call number
    // is passed in X8:

    #define svcReg x8
    #define OSint  0

#endif

在 Linux 和 macOS 下,exit 函数期望在 X0 寄存器中接收一个整数参数,该参数保存程序的返回码(如果程序运行时没有发生错误,通常为 0)。剩下的唯一问题是,“exit() 的系统调用号是多少?”在 Linux 下,代码是 93,而在 macOS 下是 1(我将在第 16.3 节“svc 接口与操作系统可移植性”中讨论我是如何确定这些魔法数字的,详情请见下一页)。列表 16-2 提供了一个非常简单的汇编应用程序,它立即返回到操作系统,您可以为 macOS 或 Linux 编译它。

// Listing16-2.S
//
// Simple shell program that calls exit()

      ❶ #include    "aoaa.inc"

        // Specify OS-dependent return code:

      ❷ #ifdef      isMacOS
        #define     exitCode 1
        #else
        #define     exitCode 93
        #endif

        .text
        .global     _main
        .global     main
        .align      2

_main:
main:
      ❸ mov         x0, #0  // Return success.
      ❹ mov         svcReg, exitCode
      ❺ svc         #OSint

列表 16-2 包含 aoaa.inc ❶ 以便在命令行中未定义操作系统符号(Linux 或 Darwin)时生成错误(aoaa.inc 将其转换为 isLinux 或 isMacOS),并获取 OSint 和 svcReg 常量。

该程序使用条件汇编来生成适用于 macOS 或 Linux 的不同代码,将常量 exitCode 设置为操作系统的退出函数号 ❷。该函数将 0 ❸ 加载到 X0 寄存器中,表示成功并返回。然后,它将 exitCode 函数号加载到操作系统的函数号参数寄存器 ❹(在 Linux 中为 X8,在 macOS 中为 X16,如 aoaa.inc 中定义,参见前面的示例)。最后,代码发出超级用户调用指令,调用操作系统 ❺。由于此调用的性质,svc 指令永远不会将控制返回给程序,因此无需 ret 指令。

以下是构建列表 16-2 中程序的 makefile:

# Listing16-2.mak
#
# makefile to build the Listing16-2 file

unamestr=`uname`

Listing16-2:
    g++ -D$(unamestr) Listing16-2.S -o Listing16-2

clean:
    rm -f Listing16-2.o
    rm -f Listing16-2

要构建并运行此程序,请在 shell 程序中输入以下命令:

% make -f Listing16-2.mak
g++ -D`uname` Listing16-2.S -o Listing16-2
% ./Listing16-2

程序按预期返回,而没有产生任何输出。

16.3 svc 接口与操作系统可移植性

macOS 和 Linux 都使用主管调用指令(svc)向操作系统发出 API 调用。然而,两个操作系统之间的实际调用顺序差异很大。本节将阐明它们在支持的功能(API)方面的区别,特别是在调用号、参数和错误处理方面。

尽管这两个操作系统都是基于 Unix 的(并共享许多符合 POSIX 标准的功能),每个操作系统都有自己的一套特定的操作系统函数,这些函数在另一个系统中可能没有对应的功能。即使是常见的(例如 POSIX)函数,也可能期望不同的参数并产生不同的返回结果,这意味着在编写能够在这两个操作系统之间移植的汇编代码时,必须特别小心。这是一个很好的例子,说明使用包装器来本地化操作系统系统调用有助于提高代码的可移植性和可维护性。

16.3.1 调用号

如前所述,函数调用号在不同操作系统之间有所不同,传递调用号的位置也不同(Linux 使用 X8,macOS 使用 X16)。通过使用#define(或.reqdirective)来克服寄存器位置问题相对容易。然而,函数调用号的值完全依赖于操作系统。

sys/syscall.h文件是一个头文件,包含了所有系统 API 调用号的定义。(即使它是一个 C 头文件,你也可以在汇编语言源文件中包含它。)当你安装 C 编译器(如 GCC 或 Clang)时,这个文件通常会安装到你的系统中,并通常位于编译器默认的包含路径中。有关更多详细信息,请参见 GCC 或 Xcode 文档。

虽然#包括<sys/syscall.h>在 Linux 和 macOS 上都能工作,但实际的定义可能出现在编译器目录树中的其他文件中,sys/syscall.h中会有适当的#include 指令来指向实际文件。

下面是 macOS 机器上sys/syscall.h文件中的几行内容:

#ifdef __APPLE_API_PRIVATE
#define SYS_syscall        0
#define SYS_exit           1
#define SYS_fork           2
#define SYS_read           3
#define SYS_write          4
#define SYS_open           5
#define SYS_close          6
#define SYS_wait4          7
   .
   .
   .

该代码中的#ifdef 语句是一个警告,Apple 认为 svc API 接口是未记录的并且是私有的,正如下一页“在 macOS 下使用 svc”框中所讨论的那样。

在我的 macOS 系统上,我使用 Unix 的 find 命令定位sys/syscall.h,它深埋在 Xcode 目录路径/Library/Developer/CommandLineTools/SDK/ ... 中,但你的情况可能不同。

在 Debian Linux 下,#include <sys/syscall.h>包括了/usr/include/asm-generic/unistd.h(如果该文件不在此位置,可以再次使用 Unix 的 find 命令)。以下是该文件中的几行内容,按与 macOS syscall.h文件中语句的顺序排列:

#define __NR_exit 93
__SYSCALL(__NR_exit, sys_exit)
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
#define __NR_write 64
__SYSCALL(__NR_write, sys_write)
#define __NR_openat 56
__SYSCALL(__NR_openat, sys_openat)
#define __NR_close 57
__SYSCALL(__NR_close, sys_close)
   .
   .
   .

如你所见,两个文件中的函数名、常量名和函数调用号并不一致。例如,Linux 通常更倾向于使用 openat() 而不是 open() 函数。幸运的是,macOS 也提供了 openat(),因此在两个操作系统上使用相同的函数是可能的。然而,macOS 和 Linux 对相同函数的符号名称却有很大不同,这意味着包含 sys/syscall.h 不是一个可移植的解决方案。你仍然需要提供自己的本地名称,将其映射到对应的 Linux 和 macOS 名称(建议:使用两个 syscall 封装 .S 文件,一个用于 Mac,一个用于 Linux,来解决这些问题)。

不幸的是,sys/syscall.h 头文件没有提供各个函数的参数列表。你可以在 <wbr>arm<wbr>.syscall<wbr>.sh 找到 Linux 的参数信息。例如,考虑 exit() 函数的条目:

name  reference   x8    x0               x1 x2 x3 x4 x5
exit  man/ cs/    5D    int error_code   -- -- -- -- --

这一行告诉你,X8 必须包含 0x5D(93),而 X0 必须包含退出代码(error_code)。Linux 系统调用最多有六个参数(X0 到 X5),但 exit() 只使用其中一个(除了函数调用号在 X8 中)。

在 macOS 上,你必须使用 macOS 的调用号(exit 的调用号为 1),并将该调用号加载到 X16 中。参数通常对于等效的 macOS 函数相同,但当然受到 Linux 与 macOS ABI 差异的影响(请参阅第 16.8 节,“更多信息”,在 930 页 中查看系统调用的集合)。

16.3.2 API 参数

一般来说,所有系统调用都有明确定义的名称和参数列表,你可以通过搜索函数名称或使用命令行的 man 命令在线查找。例如,openat() 调用具有以下参数(来自 Linux 手册页):

int openat(int dfd, const char *pathname, int flags, mode_t mode);

openat 函数的代码(在 Linux 中为 57,在 macOS 中为 463)放在 X8(Linux)或 X16(macOS)寄存器中,目录描述符放在 X0 中,指向文件名(路径名)的指针放在 X1 中,标志参数传入 X2(一个可选的模式参数可以传入 X3)。你需要根据 ARM ABI 选择参数寄存器。

为了创建在 macOS 和 Linux 之间可移植的代码,你可以在源文件的开头使用以下条件汇编,根据操作系统选择常量:

 #include "aoaa.inc"
    #include <sys/syscall.h>

#if isMacOS

    #define  sys_openat SYS_openat
    #define  sys_read SYS_read
    #define  sys_close SYS_close

#elif isLinux

    #define  sys_openat __NR_openat
    #define  sys_read __NR_read
    #define  sys_close __NR_close

#endif

从此之后,你可以在任一操作系统上使用 sys_* 符号。当然,如果你不需要在两个操作系统之间的可移植性,你可以简单地包含 sys/syscall.h 并根据你的操作系统选择适当使用 SYS_* 或 NR* 符号。

16.3.3 API 错误处理

macOS 和 Linux API 调用之间的另一个重大区别在于它们返回错误指示的方式。对于 macOS API 调用,错误状态通过进位标志返回(C = 1 表示错误,C = 0 表示没有错误)。如果进位标志被设置,macOS 会在 X0 寄存器中返回一个错误代码。而 Linux 则是在 X0 中返回–1 来表示错误;然后你必须从 errno 变量中获取实际的错误代码(例如,参见第 358 页中的第 7-2 号清单)。

在可移植代码中处理错误返回值可能会存在问题。一种解决方案是使用一组包装函数,以操作系统特定的方式处理每个操作系统中的错误。我选择创建一个小宏,将错误返回状态转换为 macOS 和 Linux 下通用的值:

 .macro  checkError

            #if     isMacOS

            // If macOS, convert the error code to be
            // compatible with Linux (carry set is
            // error flag and X0 is error code):

          ❶ bcc     0f
          ❷ neg     x0, x0

            #elif   isLinux

            // If Linux, fetch the errno error code
            // (if return value is -1), negate it,
            // and return that as the error code:

          ❸ cmp     x0, #-1
            bne     0f
          ❹ getErrno
            neg     x0, x0

            #endif
0:
            .endm

在 macOS 下,如果进位标志未设置 ❶,则此宏不做任何操作(没有错误)。如果发生错误(进位标志设置),宏会取反 X0 中的值 ❷(目前是一个正的错误代码)。

在 Linux 中,错误通过在 X0 中返回–1 来表示,在这种情况下,代码必须从 errno 变量中检索实际的错误代码。如果 API 函数返回–1 ❸,代码将获取 errno 的值 ❹(这是一个正数),并对其取反。

这个宏假设 API 函数在没有错误时会在 X0 中返回一个非负值,因此如果发生错误,它会返回实际错误代码的相反值。这将提供一组一致的符号值,你可以在任何操作系统下进行测试。

尽管 checkError 宏生成了一组可移植的错误代码,但不要假设这两个操作系统在任何给定情况下会产生完全相同的错误代码。在相同的情况下,它们更可能产生略有不同的错误代码。至少,你应该能够处理任何 macOS 或 Linux 手册页中列出的给定 API 函数的错误返回代码(这再次证明了使用包装函数来处理可移植代码中的错误代码的必要性)。

你可以从errno.h文件(或它可能包含的其他文件)中提取适当的定义;这将允许你在汇编源代码中引用类似 EPERM 或 EACCES 的 Unix 兼容常量名。别忘了,checkError 宏会取反错误代码,因此你需要与取反后的errno.h常量进行比较(例如,-EPERM 或-EACCES)。

16.4 独立的“Hello, World!”程序

按惯例,编写独立汇编程序时第一个“真实”的程序是“Hello, world!”在 Linux 和 macOS 下,你可以使用系统的 write()函数将字符串写入标准输出设备,如第 16-3 号清单所示。

// Listing16-3.S
//
// A stand-alone "Hello, world!" program

        #include    "aoaa.inc"
      ❶ #include    <sys/syscall.h>

        // Specify OS-dependent return code:

        #if         isMacOS

      ❷ #define     exitCode    SYS_exit
        #define     sys_write   SYS_write

        #else

      ❸ #define     exitCode    __NR_exit
        #define     sys_write   __NR_write

        #endif

        .data
hwStr:  .asciz      "Hello, world!\n"
hwSize  =           .-hwStr

 .text
       .global      _main
       .global      main
       .align       2

_main:
main:

      ❹ mov         x0, #1          // stdout file handle
        lea         x1, hwStr       // String to print
        mov         x2, #hwSize     // Num chars to print
        mov         svcReg, #sys_write
        svc         #OSint          // Call OS to print str.

      ❺ mov         svcReg, #exitCode
        mov         x0, #0
        svc         #OSint          // Quit program.

在清单 16-3 中,#include 语句加载了操作系统特定的常量名称,用于 API 函数调用的值 ❶。该代码定义了 macOS 的系统调用常量 ❷。由于 write 符号已经在 aoaa.inc 头文件中定义(即 C 标准库的 write() 函数),我使用了 sys_write 来避免命名空间污染。同样,代码还定义了 Linux 系统调用常量 ❸。

调用系统 API write() 函数会打印 "Hello, world!" ❹。这个调用期望将字符串的指针放入 X1 寄存器,字符串的长度放入 X2 寄存器,以及文件描述符的值放入 X0 寄存器。对于 stdout 设备,文件描述符是 1。最后,我包含了通常的程序终止代码 ❺。

请注意,C 标准库的 write() 函数不过是直接调用 Linux write() API 函数的外观代码。如果我们愿意与 C 代码链接,我们也可以通过调用 write() 来实现相同的功能,但这样做会违背本章的目的。

以下是将构建清单 16-3 中程序的 makefile:

# Listing16-3.mak
#
# makefile to build the Listing16-3 file

unamestr=`uname`

Listing16-3:
    g++ -D$(unamestr) Listing16-3.S -o Listing16-3

clean:
    rm -f Listing16-3.o
    rm -f Listing16-3

以下是构建和运行程序的命令,以及示例输出:

% make -f Listing16-3.mak clean
rm -f Listing16-3.o
rm -f Listing16-3
% make -f Listing16-3.mak
g++ -D`uname` Listing16-3.S -o Listing16-3
% ./Listing16-3
Hello, world!

请注意,程序并没有打印“调用清单 16-3”或“清单 16-3 终止”。这些输出是由 c.cpp 中的 main() 函数产生的,而这段代码并没有使用它。

16.5 一个示例文件 I/O 程序

文件 I/O 在本书中至今一直缺席。尽管使用 C 标准库中的 fopen、fclose 和 fprintf 等函数可以轻松实现读写文件数据,但 Linux 和 macOS 的 API 提供了许多有用的函数(C 标准库就是基于这些函数构建的),可以用于此目的。本节将介绍其中的一些函数:

open    打开(或创建)一个文件,用于读取、写入或追加。

read    从打开的文件中读取数据。

write    将数据写入一个打开的文件。

close    关闭一个打开的文件。

出于本示例的目的,我将实现这些调用为一个名为 files 的库,包含三个源模块:

volatile.S    一对用于保存和恢复所有易失性寄存器的工具函数。

stdio.S    一组 I/O 例程,用于将数据写入 stdout 设备并从 stdin 设备读取数据(控制台 I/O)。

files.S    一组用于打开、读取、写入和关闭文件的例程。

我将这些文件放在一个 files 子目录中,并提供了一个 files.mak makefile,它将汇编这些文件并将它们放入一个 file.a 归档文件中。以下是该 makefile:

# files.mak
#
# makefile to build the files library

unamestr=`uname`

files.a:files.o stdio.o volatile.o
    ar rcs files.a files.o stdio.o volatile.o
  ❶ cp files.a ..

files.o:files.S files.inc ../aoaa.inc
    g++ -c -D$(unamestr) files.S

stdio.o:stdio.S files.inc ../aoaa.inc
    g++ -c -D$(unamestr) stdio.S

volatile.o:volatile.S files.inc ../aoaa.inc
    g++ -c -D$(unamestr) volatile.S

clean:
    rm -f files.o
    rm -f volatile.o
    rm -f stdio.o
    rm -f files.a

在这个 makefile 成功构建源文件(并将它们组合成 file.a 归档文件)后 ❶,它将 file.a 复制到父目录中,应用程序会在那里使用 files.a

在讨论文件库的源文件之前,我将首先介绍 files.inc 头文件,因为它包含了库和应用程序源代码都将使用的定义:

// files.inc
//
// Header file that holds the files library
// globals and constants

            #include "../aoaa.inc"  // Get isMacOS and isLinux.
❶ #if isMacOS
#define __APPLE_API_PRIVATE
#endif
            #include        <sys/syscall.h>

            #if     isMacOS

❷ sys_Read    =       SYS_read
sys_Write   =       SYS_write
sys_Open    =       SYS_openat
sys_Close   =       SYS_close
AT_FDCWD    =       -2

#define O_CREAT     00000200

            #else

❸ sys_Read    =       __NR_read
sys_Write   =       __NR_write
sys_Open    =       __NR_openat
sys_Close   =       __NR_close
AT_FDCWD    =       -100

#define O_CREAT     00000100

            #endif

// Handles for the stdio files:

❹ stdin       =       0
stdout      =       1
stderr      =       2

// Other useful constants:

cr          =       0xd     // Carriage return (ENTER)
lf          =       0xa     // Line feed/newline char
bs          =       0x8     // Backspace

// Note the following are octal (base 8) constants!
// (Leading 0 indicates octal in Gas.)
//
// These constants were copied from fcntl.h.

❺ #define S_IRWXU  (00700)
#define S_RDWR   (00666)
#define S_IRUSR  (00400)
#define S_IWUSR  (00200)
#define S_IXUSR  (00100)
#define S_IRWXG  (00070)
#define S_IRGRP  (00040)
#define S_IWGRP  (00020)
#define S_IXGRP  (00010)
#define S_IRWXO  (00007)
#define S_IROTH  (00004)
#define S_IWOTH  (00002)
#define S_IXOTH  (00001)
#define S_ISUID  (0004000)
#define S_ISGID  (0002000)
#define S_ISVTX  (0001000)

#define O_RDONLY    00000000
#define O_WRONLY    00000001
#define O_RDWR      00000002
#define O_EXCL      00000200
#define O_NOCTTY    00000400
#define O_TRUNC     00001000
#define O_APPEND    00002000
#define O_NONBLOCK  00004000
#define O_DSYNC     00010000
#define FASYNC      00020000
#define O_DIRECT    00040000
#define O_LARGEFILE 00100000
#define O_DIRECTORY 00200000
#define O_NOFOLLOW  00400000
#define O_NOATIME   01000000
#define O_CLOEXEC   02000000

// Macro to test an error return
// value from an OS API call:

          ❻ .macro  file.checkError

            #if     isMacOS

 // If macOS, convert the error code to be
            // compatible with Linux (carry set is
            // error flag, and X0 is error code):

            bcc     0f
            neg     x0, x0

            #elif   isLinux

            // If Linux, fetch the errno error code
            // (if return value is -1), negate it,
            // and return that as the error code:

            cmp     x0, #-1
            bne     0f
            getErrno
            neg     x0, x0

            #endif
0:
            .endm

          ❼ .extern saveVolatile
            .extern restoreVolatile

            .extern file.write
            .extern file.read
            .extern file.open
            .extern file.openNew
            .extern file.close

            .extern stdout.puts
            .extern stdout.newLn

            .extern stdin.read
            .extern stdin.getc
            .extern stdin.readln

如前所述,macOS 的 SYS_* 符号出现在 #ifdef 块内,如果没有定义符号 __APPLE_API_PRIVATE,则会隐藏这些定义。因此,在 macOS 下包含 sys/syscall.h 头文件时,files.inc 需要定义符号 _APPLE_API_PRIVATE,这样所有的 SYS* 标签才会被 CPP 处理 ❶。

files.inc 头文件随后定义了各种符号,这些符号的值因操作系统而异(特别是 API 函数调用编号) ❷ ❸。这个条件汇编块还定义了 O_CREAT 符号,它在两个操作系统中是不同的。

接下来,头文件定义了在库源代码和与库链接的应用程序中都将使用的各种常量 ❹。stdin、stdout 和 stderr 常量分别是标准输入设备、标准输出设备和标准错误(输出)设备的 Unix 文件描述符值。库使用 cr、lf 和 bs 作为 ASCII 字符代码常量。

接着,我插入了从 fcntl.h ❺ 中提取的几个 #define 语句(这是另一个包含有用 API 常量定义的 C/C++ 头文件;通常你会在与 syscall.h 相同的目录中找到它)。这些常量在使用 openat() 函数创建新文件时使用(你需要将这些常量提供给模式参数)。与 errno.h 类似,你不能简单地包含 fcntl.h,因为 Gas 无法处理其中出现的 C/C++ 语句。

如前所述,库在 svc 指令之后使用 file.checkError 宏 ❻ 来检查错误返回结果。最后,代码包含了所有在 files.a 库 ❼ 中出现的函数的外部定义。

16.5.1 volatiles.S 函数

volatiles.S 源文件包含了两个保存和恢复所有易失性寄存器的函数 saveVolatile 和 restoreVolatile:

// volatiles.S
//
// saveVolatile and restoreVolatile functions used
// to preserve volatile registers

            #include    "../aoaa.inc"
            #include    "files.inc"

            .code
            .align  2

// saveVolatile
//
// A procedure that will save all the volatile
// registers at the location pointed at by FP

            proc    saveVolatile, public
            stp     x0,  x1,  [fp], #16
            stp     x2,  x3,  [fp], #16
            stp     x4,  x5,  [fp], #16
            stp     x6,  x7,  [fp], #16
            stp     x8,  x9,  [fp], #16
            stp     x10, x11, [fp], #16
            stp     x12, x13, [fp], #16
            stp     x14, x15, [fp], #16
            stp     q0,  q1,  [fp], #32
            stp     q2,  q3,  [fp], #32
            stp     q4,  q5,  [fp], #32
            stp     q6,  q7,  [fp], #32
            stp     q8,  q9,  [fp], #32
            stp     q10, q11, [fp], #32
            stp     q12, q13, [fp], #32
            stp     q14, q15, [fp], #32
 ret
            endp    saveVolatile

// restoreVolatile
//
// A procedure that will restore all the volatile
// registers from the location pointed at by FP

            proc    restoreVolatile, public
            ldp     x0,  x1,  [fp], #16
            ldp     x2,  x3,  [fp], #16
            ldp     x4,  x5,  [fp], #16
            ldp     x6,  x7,  [fp], #16
            ldp     x8,  x9,  [fp], #16
            ldp     x10, x11, [fp], #16
            ldp     x12, x13, [fp], #16
            ldp     x14, x15, [fp], #16
            ldp     q0,  q1,  [fp], #32
            ldp     q2,  q3,  [fp], #32
            ldp     q4,  q5,  [fp], #32
            ldp     q6,  q7,  [fp], #32
            ldp     q8,  q9,  [fp], #32
            ldp     q10, q11, [fp], #32
            ldp     q12, q13, [fp], #32
            ldp     q14, q15, [fp], #32
            ret
            endp    restoreVolatile

这些函数仅仅是将寄存器存储到 FP 寄存器所持有的地址处的连续位置。调用者有责任在调用 saveVolatile 或 restoreVolatile 之前保存 FP 寄存器,并将其加载为 volatile_save 结构的地址。如你所见,volatiles.S 中的代码并不会保存 FP 寄存器中的值。

saveVolatile 和 restoreVolatile 的目的是克服操作系统 API 调用可能修改易失性寄存器集的问题。在汇编语言编程中,除非明确地在寄存器中返回结果,否则保持寄存器的值是良好的编程风格。volatiles.S 函数使你能够在调用会破坏易失性寄存器的低级 API 函数时,仍然遵循这一编程风格。

这些函数的一个缺点是,你永远无法知道给定的 API 函数可能修改哪些易变寄存器,因此你必须保存所有的易变寄存器,即使 API 函数只改变其中的少数几个。不幸的是,这会给代码带来低效;读写内存并不特别快速。然而,在汇编语言代码中不必担心易变寄存器的保存,还是值得这点小小的效率损失的。(而且,文件 I/O 通常本身就是一个相对较慢的过程,所以如果你频繁调用文件 I/O 函数,保存和恢复寄存器的开销可能在运行时间中所占的比例非常小。)

aoaa.inc头文件包含了以下结构,用来定义 saveVolatile 保存的寄存器布局,并由 restoreVolatile 加载:

struct  volatile_save
qword   volatile_save.x0x1
qword   volatile_save.x2x3
qword   volatile_save.x4x5
qword   volatile_save.x6x7
qword   volatile_save.x8x9
qword   volatile_save.x10x11
qword   volatile_save.x12x13
qword   volatile_save.x14x15
qword   volatile_save.v0
qword   volatile_save.v1
qword   volatile_save.v2
qword   volatile_save.v3
qword   volatile_save.v4
qword   volatile_save.v5
qword   volatile_save.v6
qword   volatile_save.v7
qword   volatile_save.v8
qword   volatile_save.v9
qword   volatile_save.v10
qword   volatile_save.v11
qword   volatile_save.v12
qword   volatile_save.v13
qword   volatile_save.v14
qword   volatile_save.v15
ends    volatile_save

由于这个结构体相当大,saveVolatile 和 restoreVolatile 不涉及单独的字段。某些成员的偏移量过大,无法在 32 位加载指令的寻址模式偏移字段中编码。不过,这些结构体确实记录了 saveVolatile 和 restoreVolatile 放置数据的位置。

16.5.2 files.S 文件 I/O 函数

files.S源文件包含了库中的文件 I/O 函数。由于这个文件相当长,我会将它分成几部分,依次进行讨论。(我不会包含你传递给这些函数的参数值;这些参数在线上有很好的文档,或者你可以使用 Unix 的 man 命令来查询 read()、write()、open()、openat()和 close()函数。)

大多数的files.S函数是外观代码——也就是说,它们存在的目的是改变另一个函数(在本例中是操作系统 API 函数)的环境或参数。这些函数会保存易变寄存器,这样调用者就不需要担心它们的保存问题;在少数情况下(如 open 调用),它们会为调用者自动设置某些默认参数;或者在发生错误时,它们会修改返回码,以便在不同的操作系统中产生一致的结果。file.write 函数演示了如何提供统一的接口(跨操作系统),保存易变寄存器,并返回一致的错误码:

// files.S
//
// File I/O functions:

            #include    "../aoaa.inc"
            #include    "files.inc"

            .code
            .align  2

// file.write
//
// Write data to a file handle.
//
// X0- File handle
// X1- Pointer to buffer to write
// X2- Length of buffer to write
//
// Returns:
//
// X0- Number of bytes actually written
//     or -1 if there was an error

            proc    file.write, public

            locals  fw_locals
            qword   fw_locals.saveX0
          ❶ byte    fw_locals.volSave, volatile_save.size
            byte    fw_locals.stkspace, 64
          ❷ dword   fw_locals.fpSave
            endl    fw_locals

            enter   fw_locals.size

            // Preserve all the volatile registers because
            // the OS API write function might modify them.
            //
            // Note: Because fw_locals.volSave is at the
            // bottom of the activation record, SP just
            // happens to be pointing at it right now.
            // Use it to temporarily save FP so you can
            // pass the address of fw_locals.volSave to
            // saveVolatile in the FP register.

          ❸ str     fp, [sp]    // fw_locals.fpSave
            add     fp, fp, #fw_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

            // Okay, now do the write operation (note that
            // the sys_Write arguments are already sitting
 // in X0, X1, and X2 upon entry into this
            // function):

          ❹ mov     svcReg, #sys_Write
            svc     #OSint

            // Check for error return code:

          ❺ file.checkError

            // Restore the volatile registers, except
            // X0 (because we return the function
            // result in X0):

          ❻ str     x0, [fp, #fw_locals.saveX0] // Return value.
            str     fp, [sp]    // fw_locals.fpSave
            add     fp, fp, #fw_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fw_locals.saveX0]
            leave
            endp    file.write

在激活记录中,file.write 为易变寄存器保存区域❶和特殊变量 fw_locals.fpSave❷保留了空间。代码会使用这个变量在调用 saveVolatile 和 restoreVolatile 时保存 FP 寄存器。注意,fw_locals.fpSave 出现在激活记录的最后,因此当 file.write 构建激活记录时,它将位于栈顶。这是一个临时变量,当系统调用使用栈顶的空间时(假设它们这样做),该变量将不再使用。

接下来,file.write 会将所有的易失性寄存器保存到易失性保存区(fw_locals.volSave) ❸。因为 saveVolatile 期望 FP 指向保存区,所以这段代码将 FP 保存到栈顶(这恰好是 fw_locals.fpSave 变量的位置),然后将 FP 加载为 fw_locals.volSave 结构体的地址,调用 saveVolatile,并在返回时恢复 FP。

请注意,代码不能通过使用 [FP, #fw_locals.fpSave] 寄存器寻址模式来引用 fw_locals.fpSave 变量。首先,激活记录的大小太大,fw_locals.fpSave 的偏移量无法编码进 32 位指令。其次,FP 在从 saveVolatile 返回时并不指向激活记录,因此 [FP, #fw_locals.fpSave] 寄存器寻址模式会引用错误的位置(即使偏移量没有太大问题)。

然后,代码实际调用了 API 函数 ❹。这段代码几乎可以说是微不足道的,因为 write() 函数所需要的所有参数已经在相应的寄存器中,它们已经通过这些寄存器传递给了 file.write。

代码会检查是否发生了错误,并在写入操作过程中如果发生错误,将 X0 中的值进行调整 ❺。file.write 函数会恢复先前保存在 fw_locals.volSave 中的寄存器 ❻。这段代码和解释几乎与 ❸ 中的相同,唯一的不同是:X0 中的值。因为这段代码将函数结果返回在 X0 中,并且 restoreVolatile 会将 X0 恢复到原来的值,所以这段代码必须在调用 restoreVolatile 之前保存和恢复 X0。由于变量 fw_locals.saveX0 在激活记录中出现在 fw_locals.volSave 之前,所以在使用 [FP, #fw_locals.saveX0] 寄存器寻址模式时不会有偏移量过大的问题;只有出现在 fw_locals.volSave 之后的变量,其偏移量会太大,无法在该寻址模式下使用。

file.read 函数几乎与 file.write 函数相同:

// files.S (cont.)
//
// file.read
//
// Read data from a file handle.
//
// X0- File handle
// X1- Pointer to buffer receive data
// X2- Length of data to read
//
// Returns:
//
// X0- Number of bytes actually read
//     or negative value if there was an error

            proc    file.read, public

            locals  fr_locals
            qword   fr_locals.saveX0
            byte    fr_locals.volSave, volatile_save.size
            byte    fr_locals.stkspace, 64
            dword   fr_locals.fpSave
            endl    fr_locals

            enter   fr_locals.size

            // Preserve all the volatile registers because
            // the OS API read function might modify them.
            //
            // Note: Because fr_locals.volSave is at the
            // bottom of the activation record, SP just
            // happens to be pointing at it right now.
            // Use it to temporarily save FP so we can
            // pass the address of fr_locals.volSave to
            // saveVolatile in the FP register.

            str     fp, [sp]    // fr_locals.fpSave
            add     fp, fp, #fr_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

            // Okay, now do the read operation (note that
            // the sys_Read arguments are already sitting
 // in X0, X1, and X2 upon entry into this
            // function):

            mov     svcReg, #sys_Read
            svc     #OSint

            // Check for error return code:

            file.checkError

            // Restore the volatile registers, except
            // X0 (because we return the function
            // result in X0):

            str     x0, [fp, #fr_locals.saveX0] // Return value.
            str     fp, [sp]    // fr_locals.fpSave
            add     fp, fp, #fr_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fr_locals.saveX0]
            leave
            endp    file.read

file.read 和 file.write 唯一的真正区别在于 svcReg 寄存器中加载的函数号。

接下来,files.S 提供了 file.open 函数的代码:

// files.S (cont.)
//
// file.open
//
// Open existing file for reading or writing.
//
// X0- Pointer to pathname string (zero-terminated)
// X1- File access flags
//     (O_RDONLY, O_WRONLY, or O_RDWR)
//
// Returns:
//
// X0- Handle of open file (or negative value if there
//     was an error opening the file)

            proc    file.open, public

            locals  fo_locals
            qword   fo_locals.saveX0
            byte    fo_locals.volSave, volatile_save.size
            byte    fo_locals.stkspace, 64
            dword   fo_locals.fpSave
            endl    fo_locals

            enter   fo_locals.size

 // Preserve all the volatile registers because
            // the OS API open function might modify them:

            str     fp, [sp]    // fo_locals.fpSave
            add     fp, fp, #fo_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

            // Call the OS API open function:

          ❶ mov     svcReg, #sys_Open
            mov     x2, x1
            mov     x1, x0
            mov     x0, #AT_FDCWD
            mov     x3, #S_RDWR     // Mode, usually ignored
            svc     #OSint

            // Check for error return code:

            file.checkError

            // Restore the volatile registers, except
            // X0 (because we return the function
            // result in X0):

            str     x0, [fp, #fo_locals.saveX0] // Return value.
            str     fp, [sp]    // fo_locals.fpSave
            add     fp, fp, #fo_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fo_locals.saveX0]
            leave
            endp    file.open

file.open 函数与 file.write 和 file.read 函数相同,唯一不同的是调用了操作系统的 API 函数 ❶。file.open 并不是调用 API 的 open() 函数,而是调用了 openat() 函数,这是 open() 函数的一个更现代的版本。以下是这两个函数的 C/C++ 原型:

int open(const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags);

openat() 函数有一个额外的参数 int dirfd。这使得问题变得复杂,因为 file.open 期望的参数与 open() 函数相同;因此,在进入 file.open 时,参数位于错误的寄存器中,无法直接调用 openat()。

这可以通过将 X1 移至 X2,X0 移至 X1,然后将 X0 加载为 AT_FDCWD 值来修复,从而使 openat()函数表现得像 open()函数一样❶。open()和 openat()函数各有一个可选的第三个或第四个参数(分别),允许你在创建新文件时设置权限。file.open 函数用于打开已存在的文件,因此在调用它时通常不需要指定那个额外的参数。然而,万一调用者在 X1 中指定了 O_CREAT,代码就会将 X3 设置为一个合理的值(为所有人设置读写权限)。

file.openNew 函数是 file.open 的一个变体,用于创建新文件:

// files.S (cont.)
//
// file.openNew
//
// Creates a new file and opens it for writing
//
// X0- Pointer to filename string (zero-terminated)
//
// Returns:
//
// X0- Handle of open file (or negative if there
//     was an error creating the file)

            proc    file.openNew, public

            locals  fon_locals
            qword   fon_locals.saveX0
            byte    fon_locals.volSave, volatile_save.size
            byte    fon_locals.stkspace, 64
            dword   fon_locals.fpSave
            endl    fon_locals

            enter   fon_locals.size

            // Preserve all the volatile registers because
            // the OS API open function might modify them:

            str     fp, [sp]    // fon_locals.fpSave
            add     fp, fp, #fon_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

            // Call the OS API open function:

            mov     svcReg, #sys_Open
            mov     x2, #O_CREAT+O_WRONLY+O_EXCL
            mov     x1, x0
            mov     x0, #AT_FDCWD
            mov     x3, #S_RDWR // User/Group has RW perms.
            svc     #OSint

            // Check for error return code:

            file.checkError

            // Restore the volatile registers, except
            // X0 (because we return the function
 // result in X0):

            str     x0, [fp, #fon_locals.saveX0] // Return value.
            str     fp, [sp]    // w_locals.fpSave
            add     fp, fp, #fon_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fon_locals.saveX0]
            leave
            endp    file.openNew

file.openNew 和 file.open 之间唯一的区别是,file.openNew 只期望一个参数(X0 中的路径名),并为调用 openat()自动提供标志值(O_CREAT+O_WRONLY+O_EXCL)。

file.close 函数是files.S源文件中的最后一个文件 I/O 函数:

// files.S (cont.)
//
// file.close
//
// Closes a file specified by a file handle
//
// X0- Handle of file to close

            proc    file.close, public

            locals  fc_locals
            qword   fc_locals.saveX0
            byte    fc_locals.volSave, volatile_save.size
            byte    fc_locals.stkspace, 64
            dword   fc_locals.fpSave
            endl    fc_locals

            enter   fc_locals.size

            // Preserve all the volatile registers because
            // the OS API open function might modify them:

            str     fp, [sp]    // fc_locals.fpSave
            add     fp, fp, #fc_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

            // Call the OS API close function (handle is
            // already in X0):

            mov     svcReg, #sys_Close
            svc     #OSint

            // Check for error return code:

            file.checkError

 // Restore the volatile registers, except
            // X0 (because we return the function
            // result in X0):

            str     x0, [fp, #fc_locals.saveX0] // Return value.
            str     fp, [sp]    // w_locals.fpSave
            add     fp, fp, #fc_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fc_locals.saveX0]
            leave
            endp    file.close

file.close 函数期望在 X0 中传入文件描述符(由成功调用 file.open 或 file.openNew 返回),并将该描述符传递给 API 的 close()函数。否则,它的形式与 file.read 和 file.write 函数类似。

16.5.3 stdio.S 函数

文件库中的最后一个源文件是stdio.S文件。这个模块包含了你可以用来在控制台(标准 I/O)设备上读写字符串的函数。由于它的大小,我将这个源文件拆分成了更易于消化的部分。

首先,stdout.puts 函数将一个(以零结束的)字符串写入标准输出设备(通常是显示控制台):

// stdio.S
//
// Standard input and standard output functions:

            #include    "../aoaa.inc"
            #include    "files.inc"
            #include    <sys/syscall.h>

            .code
            .align  2

// stdout.puts
//
// Outputs a zero-terminated string to standard output device
//
// X0- Address of string to print to standard output

            proc    stdout.puts, public

            locals  lcl_puts
          ❶ qword   lcl_puts.saveX0X1
            dword   lcl_puts.saveX2
            byte    lcl_puts.stkSpace, 64
            endl    lcl_puts

            enter   lcl_puts.size

 stp     x0, x1, [fp, #lcl_puts.saveX0X1]
            str     x2,     [fp, #lcl_puts.saveX2]

            mov     x1, x0

// Compute the length of the string:

❷ lenLp:      ldrb    w2, [x1], #1
            cbnz    w2, lenLp
            sub     x2, x1, x0  // Compute length

            // Call file_write to print the string:

          ❸ mov     x1, x0
            mov     x0, #stdout
            bl      file.write

            // Return to caller:

            ldr     x2,     [fp, #lcl_puts.saveX2]
            ldp     x0, x1, [fp, #lcl_puts.saveX0X1]
            leave
            endp    stdout.puts

请注意,这段代码并没有保存所有的易失性寄存器❶,因为 stdout.puts 函数并没有直接调用可能修改寄存器的操作系统 API 函数。因此,这个函数只保留它实际使用的寄存器。

这个函数将调用 file.write 将字符串写入标准输出设备。file.write 函数需要三个参数:文件描述符(stdout 常量对于描述符值非常合适)、数据的地址(字符串)以及长度值。虽然 stdout.puts 函数在 X0 中具有字符串的地址,但没有长度参数。因此,这段代码计算了地址在 X0 中的以零结束的字符串的长度❷。

注意

一次扫描一个字节来计算字符串长度是一个简陋的方式,但我在这里使用它,因为它比其他方法更简单。如果这真的让你感到困扰,你可以链接 C 标准库 strlen() 函数。不过,请记住,进行系统调用并在屏幕上绘制所有这些像素来打印字符串的速度比这个字符串长度计算慢得多,因此使用更快的字符串长度计算代码并不会节省多少时间。

一旦计算出长度,stdout.put 函数会调用 file.write 将字符串实际打印到标准输出设备 ❸。在恢复这段代码修改过的几个寄存器后,函数返回。

从技术上讲,file.write 可能返回一个错误代码(stdout.puts 会忽略这个错误并且不会将其返回给调用者)。然而,这种错误的可能性较低,因此这段代码忽略了错误。一个可能的问题是,如果标准输出被重定向到磁盘文件,而写入磁盘时出现问题,那么这个 bug 就值得关注。如果这个例程要进入生产代码,应该解决这个问题;我选择在这里不处理此问题,以保持代码的简洁(而且发生这种情况的概率极低)。

stdout.newLn 函数与 stdout.puts 相同,只是它会向标准输出设备写入一个固定的字符串(换行符):

// stdio.S (cont.)
//
// stdout.newLn
//
// Outputs a newline sequence to the standard output device:

stdout.nl:  .ascii  "\n"
nl.len      =       .-stdout.nl
            .byte   0
            .align  2

            proc    stdout.newLn, public
            locals  lcl_nl
            qword   lcl_nl.saveX0X1
            dword   lcl_nl.saveX2
            byte    lcl_nl.stkSpace, 64
            endl    lcl_nl

            enter   lcl_nl.size
            stp     x0, x1, [fp, #lcl_nl.saveX0X1]
            str     x2,     [fp, #lcl_nl.saveX2]

            lea     x1, stdout.nl
            mov     x2, #nl.len
            mov     x0, stdout
            bl      file.write

            ldr     x2,     [fp, #lcl_nl.saveX2]
            ldp     x0, x1, [fp, #lcl_nl.saveX0X1]
            leave
            endp    stdout.newLn

stdin.read 函数是 stdout.write 的输入补充:

// stdio.S (cont.)
//
// stdin.read
//
// Reads data from the standard input
//
// X0- Buffer to receive data
// X1- Buffer count (note that data input will
//     stop on a newline character if that
//     comes along before X1 characters have
//     been read)
//
// Returns:
//
// X0- Negative value if error, bytes read if successful

             proc    stdin.read, public
             locals  sr_locals
             qword   sr_locals.saveX1X2
             byte    sr_locals.stkspace, 64
             dword   sr_locals.fpSave
             endl    sr_locals

             enter   sr_locals.size
             stp     x1, x2, [fp, #sr_locals.saveX1X2]

             // Call the OS API read function:

           ❶ mov     svcReg, #sys_Read
             mov     x2, x1
             mov     x1, x0
             mov     x0, #stdin
             svc     #OSint

             file.checkError

             ldp     x1, x2, [fp, #sr_locals.saveX1X2]
             leave
             endp    stdin.read

当你传递给 stdin.read 一个缓冲区的地址和大小时,它会从标准输入设备(通常是键盘)读取指定数量的字符,并将这些字符放入缓冲区。当它读取到指定数量的字符或者遇到换行符(换行符)时,读取会停止。

这段看似简单的代码的关键在于,在调用操作系统的 read() 函数之前,需要先移动地址和字节数 ❶。这是因为在调用 read() 时,缓冲区的地址需要放入 X1 和 X2 寄存器中,而函数必须将标准输入文件描述符加载到 X0 寄存器中。

stdin.getc 函数是 stdin.read 的一个字符版本:

// stdio.S (cont.)
//
// stdin_getc
//
// Read a single character from the standard input.
// Returns character in X0 register

            proc    stdin.getc, public
            locals  sgc_locals
            qword   sgc_locals.saveX1X2
          ❶ byte    sgc_buf, 16
            byte    sgc_locals.stkspace, 64
            endl    sgc_locals

 enter   sgc_locals.size
            stp     x1, x2, [fp, #sgc_locals.saveX1X2]

            // Initialize return value to all 0s:

          ❷ str     xzr, [fp, #sgc_buf]

            // Call the OS API read function to read
            // a single character:

            mov     svcReg, #sys_Read
            mov     x0, #stdin
          ❸ add     x1, fp, #sgc_buf
            mov     x2, #1
            svc     #OSint

          ❹ file.checkError
            cmp     x0, #0
            bpl     noError

            // If there was an error, return the
            // error code in X0 rather than a char:

            str     x0, [fp, #sgc_buf]

noError:
            ldp     x1, x2, [fp, #sgc_locals.saveX1X2]
          ❺ ldr     x0, [fp, #sgc_buf]
            leave

            endp    stdin.getc

stdin.getc 函数返回它在 X0 中读取的字符,而不是将其放入缓冲区。由于调用 API 的 read() 函数需要一个缓冲区,这个函数必须为缓冲区预留存储空间 ❶。从技术上讲,缓冲区只需要 8 个字符长,但这个函数预留了 16 字节,只是为了帮助保持栈的 16 字节对齐。该代码将缓冲区的前 8 个字节初始化为 0 ❷。函数实际上会返回所有 8 个字节(即使读取操作只会将一个字节存储到缓冲区)。该函数计算缓冲区的地址并将其传递给 API 的 read() 函数 ❸。

如果某种情况下,调用 API 的 read() 函数返回错误,代码将把负的错误返回代码存储在缓冲区的前 8 个字节中 ❹。在返回之前,stdin.getc 会将缓冲区开头的 8 个字节加载到 X0 寄存器中,并返回该值(这可以是一个字符加上七个 0,或者一个 UTF-8 值,或者是 8 字节的负错误代码) ❺。

注意

stdin.get 函数并不是从键盘读取单个字符并立即返回给调用者。相反,操作系统会从键盘读取整行文本,并返回该行的第一个字符。对 stdin.get 的后续调用将从该操作系统内部缓冲区读取剩余字符。这是标准的 Unix 行为,而非该函数的特定功能。

stdio.S 文件中的最后一个函数是 stdin.readln:

// stdio.S (cont.)
//
// stdin.readln
//
// Reads a line of text from the user.
// Automatically processes backspace characters
// (deleting previous characters, as appropriate).
// Line returned from function is zero-terminated
// and does not include the ENTER key code (carriage
// return) or line feed.
//
// X0- Buffer to place line of text read from user
// X1- Maximum buffer length
//
// Returns:
//
// X0- Number of characters read from the user
//     (does not include ENTER key)

            proc    stdin.readln, public
            locals  srl_locals
            qword   srl_locals.saveX1X2
            dword   srl_locals.saveX3
            byte    srl_buf, 16
            byte    srl_locals.stkspace, 64
            endl    srl_locals

            enter   srl_locals.size
            stp     x1, x2, [fp, #srl_locals.saveX1X2]
            str     x3,     [fp, #srl_locals.saveX3]

            mov     x3, x0          // Buf ptr in X3
            mov     x2, #0          // Character count
            cbz     x1, exitRdLn    // Bail if zero chars.

            sub     x1, x1, #1      // Leave room for 0 byte.
readLp:
          ❶ bl      stdin.getc      // Read 1 char from stdin.

            cmp     w0, wzr         // Check for error.
            bmi     exitRdLn

          ❷ cmp     w0, #cr         // Check for newline code.
            beq     lineDone

            cmp     w0, #lf         // Check for newline code.
            beq     lineDone

 ❸ cmp     w0, #bs         // Handle backspace character.
            bne     addChar

// If a backspace character came along, remove the previous
// character from the input buffer (assuming there is a
// previous character):

            cmp     x2, #0          // Ignore BS character if no
            beq     readLp          // chars in the buffer.
            sub     x2, x2, #1
            b.al    readLp

// If a normal character (that we return to the caller),
// add the character to the buffer if there is room
// for it (ignore the character if the buffer is full):

❹ addChar:    cmp     x2, x1          // See if you're at the
            bhs     readLp          // end of the buffer.
            strb    w0, [x3, x2]    // Save char to buffer.
            add     x2, x2, #1
            b.al    readLp

// When the user presses the ENTER key (or line feed)
// during input, come down here and zero-terminate the string:

lineDone:
          ❺ strb    wzr, [x3, x2]

exitRdLn:   mov     x0, x2          // Return char cnt in X0.
            ldp     x1, x2, [fp, #srl_locals.saveX1X2]
            ldr     x3,     [fp, #srl_locals.saveX3]
            leave
            endp    stdin.readln

这个函数主要用于交互式使用,它会从键盘读取一行文本并进行适度编辑(处理退格符),将这些字符放入缓冲区。从许多方面来看,它的工作方式就像 stdin.read,只是按下 BACKSPACE 键会删除输入缓冲区中的一个字符,而不是将退格符 ASCII 代码作为缓冲区中的字符返回。

这个函数重复调用 stdin.getc❶,每次读取一个字符。如果 stdin.getc 返回错误(负返回值),该函数会立即返回,将错误代码传递给调用者。

代码通过将字符与回车符或换行符(换行符)❷的 ASCII 代码进行比较,检查输入行是否完成。如果字符与这两者中的任何一个匹配,代码会退出循环。

然后,stdin.readln 函数会检查是否有退格符❸。如果是退格符,该函数将从输入缓冲区删除之前的字符(如果有的话)。如果字符不是退格符,代码会跳转到❹,在这里它将该字符追加到缓冲区的末尾。

当函数在输入流中找到回车符或换行符时,它会将控制权转移到❺,在那里它会将字符串终止并返回实际读取的字符数到 X0 寄存器中。

除了处理退格符,读取文本行时使用 stdin.readln 和简单调用 stdin.read 之间还有两个额外的区别。首先,stdin.readln 会将读取到缓冲区的字符串终止。其次,stdin.readln 不会将换行符(或回车符)放入缓冲区。

16.5.4 文件 I/O 演示应用

清单 16-4 中的简单应用演示了 file.a 库的使用。

// Listing16-4.S
//
// File I/O demonstration:

            #include    "aoaa.inc"
            #include    "files/files.inc"
            #include    <sys/syscall.h>

            #if isMacOS

// Map main to "_main" as macOS requires
// underscores in front of global names
// (inherited from C code, anyway).

#define main _main
sys_Exit    =   SYS_exit

            #else

sys_Exit    =   __NR_exit

            #endif

            .data

// Buffer to hold line of text read from user:

inputLn:    .space  256, (0)
inputLn.len =       .-inputLn

// Buffer to hold data read from a file:

fileBuffer: .space  4096, (0)
fileBuffer.len =    .-fileBuffer

// Prompt the user for a filename:

prompt:     .ascii  "Enter (text) filename:"
prompt.len  =       .-prompt
            .byte   0

// Error message string:

badOpenMsg: wastr   "Could not open file\n"

OpenMsg:    wastr   "Opening file: "

            .code

// Here is the asmMain function:

            proc    main, public
            locals  am
            dword   am.inHandle
            byte    am_stkSpace, 64
            endl    am

            enter   am.size

// Get a filename from the user:

          ❶ lea     x0, prompt
            bl      stdout.puts

            lea     x0, inputLn
            mov     x1, #inputLn.len
            bl      stdin.readln
            cmp     x0, #0
            bmi     badOpen

            lea     x0, OpenMsg
            bl      stdout.puts
            lea     x0, inputLn
            bl      stdout.puts
            bl      stdout.newLn

// Open the file, read its contents, and display
// the contents to the standard output device:

          ❷ lea     x0, inputLn
            mov     x1, #O_RDONLY
            bl      file.open
            cmp     x0, xzr
            ble     badOpen

            str     x0, [fp, #am.inHandle]

// Read the file 4,096 bytes at a time:

readLoop:
          ❸ ldr     x0, [fp, #am.inHandle]
            lea     x1, fileBuffer
            mov     x2, fileBuffer.len
            bl      file.read

 // Quit if there was an error or
            // file.read read 0 bytes:

            cmp     x0, xzr
            ble     allDone

            // Write the data just read to the
            // stdout device:

          ❹ mov     x2, x0        // Bytes to write
            lea     x1, fileBuffer
            mov     x0, #stdout
            bl      file.write
            b.al    readLoop

badOpen:    lea     x0, badOpenMsg
            bl      stdout.puts

allDone:
          ❺ ldr     x0, [fp, #am.inHandle]
            bl      file.close

            // Return error code 0 to the OS:

            mov     svcReg, #sys_Exit
            mov     x0, #0
            svc     #OSint
            endp    main

该程序首先提示用户输入一个文件名❶。它从用户那里读取这个文件名,然后将文件名回显到显示屏上。程序打开该文件并保存由文件 .open 返回的文件句柄❷。如果在打开文件时发生错误,程序会跳转到 badOpen 标签,打印错误信息并退出。

接下来,程序会不断地读取(最多)4,096 字节的块,直到文件末尾被读取完(或发生其他错误)❸。从文件读取时,file.read 函数会读取完整的 4,096 字节,忽略所有换行符(仅在从标准输入读取时才会在换行符处停止)。如果该函数从输入中读取到 0 字节,表示已到达文件末尾,循环会退出。

然后,代码将读取的字节写入标准输出设备 ❹,并使用 file.read 的返回值作为调用 file.write 时的字节计数。这是因为从文件读取的最后一块字节可能不是 4,096 字节;如果读取的字节少于 4,096 字节,下一次读取将返回 0 字节,操作就会完成。一旦程序完成,它会关闭文件并退出 ❺>。

这是将在清单 16-4 中构建程序的 makefile:

# Listing16-4.mak
#
# makefile to build the Listing16-4.S file

unamestr=`uname`

Listing16-4:Listing16-4.S aoaa.inc files/files.inc files.a
    cd files; make -f files.mak; cd ..
    g++ -D$(unamestr) -o Listing16-4 Listing16-4.S files.a

clean:
    rm -f Listing16-4.o
    rm -f Listing16-4
    rm -f file.a
    cd files; make -f files.mak clean; cd ..

这是一个示例构建操作和程序执行:

% make -f Listing16-4.mak clean
rm -f Listing16-4.o
rm -f Listing16-4
rm -f file.a
cd files; make -f files.mak clean; cd ..
rm -f files.o
rm -f volatile.o
rm -f stdio.o
rm -f files.a
% make -f Listing16-4.mak
cd files; make -f files.mak; cd ..
g++ -c -D`uname` files.S
g++ -c -D`uname` stdio.S
g++ -c -D`uname` volatile.S
ar rcs files.a files.o stdio.o volatile.o
cp files.a ..
g++ -D`uname` -o Listing16-4 Listing16-4.S files.a
% ./Listing16-4
Enter (text) filename:Listing16-4.mak
Opening file: Listing16-4.mak
# listing16-4.mak
#
# makefile to build the Listing16-4.S file.

unamestr=`uname`

Listing16-4:Listing16-4.S aoaa.inc files/files.inc files.a
    cd files; make -f files.mak; cd ..
    g++ -D$(unamestr) -o Listing16-4 Listing16-4.S files.a

clean:
    rm -f Listing16-4.o
    rm -f Listing16-4
    rm -f file.a
    cd files; make -f files.mak clean; cd ..

我使用了Listing16-4.mak文本文件作为这次程序运行的输入。

16.6 在 macOS 下调用系统库函数

正如我之前提到的,苹果公司对直接通过 svc 指令调用 macOS 内核的应用程序持反对态度。公司声称,正确的调用方式是通过苹果提供的 C 库代码。本章展示了低级调用,因为这本章的目的就是这个;如果你是编写 C 库代码的人(或者是类似的与操作系统交互的库代码),你需要了解这些信息。然而,如果我不向你展示苹果推荐的与 macOS 接口的方式,我就失职了。

我创建了一个变体的files.a库,存储在在线源码集中的files-macOS目录下,链接了内核的 read()、write()、open()和 close()函数。为了避免冗余,我没有在本章中打印所有代码,但我将在这里列出 file.write 函数,给你一个关于修改简单性的概念:

// file.write
//
// Write data to a file handle.
//
// X0- File handle
// X1- Pointer to buffer to write
// X2- Length of buffer to write
//
// Returns:
//
// X0- Number of bytes actually written
//     or -1 if there was an error

            proc    file.write, public
            locals  fw_locals
            qword   fw_locals.saveX0
            byte    fw_locals.volSave, volatile_save.size
            byte    fw_locals.stkspace, 64
            dword   fw_locals.fpSave
            endl    fw_locals

            enter   fw_locals.size

            // Preserve all the volatile registers because
            // the OS API write function might modify them.
            //
            // Note: because fw_locals.volSave is at the
            // bottom of the activation record, SP just
            // happens to be pointing at it right now.
            // Use it to temporarily save FP so you can
            // pass the address of w_locals.volSave to
            // saveVolatile in the FP register.

            str     fp, [sp]    // fw_locals.fpSave
            add     fp, fp, #fw_locals.volSave
            bl      saveVolatile
            ldr     fp, [sp]    // Restore FP.

 // Okay, now do the write operation (note that
            // the write arguments are already sitting
            // in X0, X1, and X2 upon entry into this
            // function):

          ❶ bl      _write

            // Check for error return code:

            file.checkError

            // Restore the volatile registers, except
            // X0 (because we return the function
            // result in X0):

            str     x0, [fp, #fw_locals.saveX0] // Return value.
            str     fp, [sp]    // w_locals.fpSave
            add     fp, fp, #fw_locals.volSave
            bl      restoreVolatile
            ldr     fp, [sp]    // Restore FP.
            ldr     x0, [fp, #fw_locals.saveX0]
            leave
            endp    file.write

这个版本的 file.write 与原始files.a库中的版本之间唯一的区别是,我将 svc 指令序列替换为对 _write()函数的调用 ❶。

新的files.a库还包括了对files.inc头文件的一些更改。最重要的更改是对 file.checkError 宏的修改:

 .macro  file.checkError

            cmp     x0, #-1
            bne     0f
            getErrno
            neg     x0, x0
0:
            .endm

macOS 的 _write()函数在发生错误时返回-1,因为 C 代码无法测试进位标志。因此,我修改了 file.checkError 函数,以便像 Linux 那样处理错误。

我必须先构建files-macOS库(以创建一个新的file.a版本,替换掉直接进行操作系统调用的版本),然后使用files-macOS中的file.a库创建Listing16-4.S。程序的运行与前一节中的原始文件 I/O 示例相同。

理论上,你可以使用相同的方法在 Linux 上操作,这样可以创建在这两种操作系统之间稍微更具可移植性的代码。然而,Linux 下的 svc API 接口已经明确定义并且有文档,所以没有理由不直接调用 API 函数。### 16.7 在没有 GCC 的情况下创建汇编应用程序

在本章中,我继续使用 GCC 来汇编和链接汇编语言文件。这是因为本章中的大多数示例代码都包含aoaa.inc,而这个文件依赖于 CPP。你可能对这种方法有所怀疑,认为 GCC 可能会偷偷把一些 C 代码引入到你的程序中。你是对的:即使你用 GCC 构建一个“纯”汇编语言程序,它也会链接一些代码,在程序执行之前设置环境(这样,如果你确实调用任何 C 库代码,环境就已经为其做好了准备)。

通常,这类额外的代码影响不大——它只执行一次,执行速度较快,并且占用的空间也不多。然而,如果你是一个绝对的纯粹主义者,并且只希望执行你自己编写的代码,你可以通过一些额外的工作做到这一点。你只是不能使用aoaa.inc,而且你必须专门为 macOS 或 Linux 编写不可移植的代码。

清单 16-5 是为 Linux 编写的“纯”汇编语言程序。

❶ // Listing16-5.s
//
// A truly stand-alone "Hello, world!" program
// written for Linux

        .text
      ❷ .global     _start
        .align      2
hwStr:  .asciz      "Hello, world!\n"
hwSize  =           .-hwStr
        .align      2

❸ _start:

        mov         x0, #1          // stdout file handle
        adr         x1, hwStr       // String to print
        mov         x2, #hwSize     // Num chars to print
        mov         X8, #64         // __NR_write
        svc         #0              // Call OS to print str.

        mov         X8, #93         // __NR_exit
        mov         x0, #0
        svc         #0              // Quit program.

请注意,这个文件名必须有小写的.s后缀 ❶;你不会使用 GCC 进行编译,因此不会在这个代码中使用 CPP。在 Linux 下,默认的程序入口点名为 _start。因此,这段代码将 _start 声明为全局符号 ❷,并使用 _start 作为程序的入口点 ❸。在本章早些时候的示例中,我可以使用 main(或 _main),因为 GCC 链接的 C 代码提供了 _start 标签并将控制权转交给 main(或 _main);然而,由于我们放弃了 GCC 生成的代码,我们必须显式地提供 _start 标签。

要汇编、链接并运行这个程序,请使用以下 Linux 命令:

as -o Listing16-5.o Listing16-5.s
ld -s -o Listing16-5 Listing16-5.o
./Listing16-5
Hello, world!

要使这个程序在 macOS 下运行,你必须首先修改源代码,以使用适当的 macOS API 常量,如清单 16-6 所示。

// Listing16-6.s
//
// A truly stand-alone "Hello, world!" program
// written for macOS

        .text
        .global     _start
        .global     _main    // Later versions of macOS require this name.
        .align      2
hwStr:  .asciz      "Hello, world!\n"
hwSize  =           .-hwStr
        .align      2

_start:
_main:

        mov         x0, #1          // stdout file handle
        adr         x1, hwStr       // String to print
        mov         x2, #hwSize     // Num chars to print
        mov         X16, #4         // SYS_write
        svc         #0x80           // Call OS to print str.

        mov         X16, #1         // SYS_exit
        mov         x0, #0
        svc         #0x80           // Quit program.

        svc         #0              // Quit program.

汇编代码与 Linux 类似(请注意,程序名后缀也是小写的.s):

as -arch arm64 -o Listing16-6.o Listing16-6.s

然而,链接程序稍微复杂一些:

ld -macos_version_min 12.3.0 -o HelloWorld Listing16-6.o \
 -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -arch arm64

在 macOS 下构建纯汇编文件时,最好使用 makefile;每次想要构建应用程序时手动输入这些命令是相当繁琐的!

如你所见,链接器(ld)命令仍然会链接一些 C 代码(libSystem)。我知道的没有其他方法可以避免这一点,这也是我非常乐意让 GCC 为我做这些工作的原因。

注意

Apple 并不是开玩笑,当它警告你不要编写这样的代码时。在我第一次编写这个“Hello, World!”程序和本章审阅之间的时间里,Apple 对其系统进行了更改,导致程序无法运行。特别是,现在链接器期望程序的名称是 _main 而不是 _start ,并且 ld 的命令行有一些微妙的变化。故事的寓意是:坚持使用 GCC(Clang)来为你做所有这些工作。

16.8 更多信息

第四部分 参考资料

第十七章:ASCII 字符集

二进制 十六进制 十进制 字符
0000_0000 00 0 NUL
0000_0001 01 1 CTRL-A
0000_0010 02 2 CTRL-B
0000_0011 03 3 CTRL-C
0000_0100 04 4 CTRL-D
0000_0101 05 5 CTRL-E
0000_0110 06 6 CTRL-F
0000_0111 07 7 响铃
0000_1000 08 8 退格
0000_1001 09 9 TAB
0000_1010 0A 10 换行
0000_1011 0B 11 CTRL-K
0000_1100 0C 12 换页
0000_1101 0D 13 回车
0000_1110 0E 14 CTRL-N
0000_1111 0F 15 CTRL-O
0001_0000 10 16 CTRL-P
0001_0001 11 17 CTRL-Q
0001_0010 12 18 CTRL-R
0001_0011 13 19 CTRL-S
0001_0100 14 20 CTRL-T
0001_0101 15 21 CTRL-U
0001_0110 16 22 CTRL-V
0001_0111 17 23 CTRL-W
0001_1000 18 24 CTRL-X
0001_1001 19 25 CTRL-Y
0001_1010 1A 26 CTRL-Z
0001_1011 1B 27 ESC (CTRL-[)
0001_1100 1C 28 CTRL-\
0001_1101 1D 29 CRTL-]
0001_1110 1E 30 CTRL-^
0001_1111 1F 31 CTRL-_
0010_0000 20 32 空格
0010_0001 21 33 !
0010_0010 22 34 "
0010_0011 23 35 #
0010_0100 24 36 $
0010_0101 25 37 %
0010_0110 26 38 &
0010_0111 27 39 '
0010_1000 28 40 (
0010_1001 29 41 )
0010_1010 2A 42 *
0010_1011 2B 43 +
0010_1100 2C 44 ,
0010_1101 2D 45 -
0010_1110 2E 46 .
0010_1111 2F 47 /
0011_0000 30 48 0
0011_0001 31 49 1
0011_0010 32 50 2
0011_0011 33 51 3
0011_0100 34 52 4
0011_0101 35 53 5
0011_0110 36 54 6
0011_0111 37 55 7
0011_1000 38 56 8
0011_1001 39 57 9
0011_1010 3A 58 :
0011_1011 3B 59 ;
0011_1100 3C 60 <
0011_1101 3D 61 =
0011_1110 3E 62 >
0011_1111 3F 63 ?
0100_0000 40 64 @
0100_0001 41 65 A
0100_0010 42 66 B
0100_0011 43 67 C
0100_0100 44 68 D
0100_0101 45 69 E
0100_0110 46 70 F
0100_0111 47 71 G
0100_1000 48 72 H
0100_1001 49 73 I
0100_1010 4A 74 J
0100_1011 4B 75 K
0100_1100 4C 76 L
0100_1101 4D 77 M
0100_1110 4E 78 N
0100_1111 4F 79 O
0101_0000 50 80 P
0101_0001 51 81 Q
0101_0010 52 82 R
0101_0011 53 83 S
0101_0100 54 84 T
0101_0101 55 85 U
0101_0110 56 86 V
0101_0111 57 87 W
0101_1000 58 88 X
0101_1001 59 89 Y
0101_1010 5A 90 Z
0101_1011 5B 91 [
0101_1100 5C 92 \
0101_1101 5D 93 ]
0101_1110 5E 94 ^
0101_1111 5F 95 _
0110_0000 60 96 `
0110_0001 61 97 a
0110_0010 62 98 b
0110_0011 63 99 c
0110_0100 64 100 d
0110_0101 65 101 e
0110_0110 66 102 f
0110_0111 67 103 g
0110_1000 68 104 h
0110_1001 69 105 i
0110_1010 6A 106 j
0110_1011 6B 107 k
0110_1100 6C 108 l
0110_1101 6D 109 m
0110_1110 6E 110 n
0110_1111 6F 111 o
0111_0000 70 112 p
0111_0001 71 113 q
0111_0010 72 114 r
0111_0011 73 115 s
0111_0100 74 116 t
0111_0101 75 117 u
0111_0110 76 118 v
0111_0111 77 119 w
0111_1000 78 120 x
0111_1001 79 121 y
0111_1010 7A 122 z
0111_1011 7B 123 {
0111_1100 7C 124 |
0111_1101 7D 125 }
0111_1110 7E 126 ~
0111_1111 7F 127 DEL

第十八章:B 词汇表

A

AARCH64

ARM 架构的 64 位变种,也被称为 ARM64。

地址

与内存位置相关的数字索引。

地址总线

一组电子信号,存储内存元素的二进制地址。

地址空间布局随机化(ASLR)

程序模块使用随机加载地址(以减少代码中被黑客攻击和利用的可能性)。

替代半精度控制位(AHP)

浮点控制寄存器中的一位,选择替代的半精度格式(不同于 IEEE 半精度格式)。

应用二进制接口(ABI)

一套允许编程语言和系统之间交互的规则。包括传递参数、数据类型和其他特性的规则。

ARM

高级 RISC 机器(最初是 Acorn RISC 机器的缩写)。

ASCII

美国信息交换标准代码(一个标准化的字符集)。

汇编器

汇编语言的编译器。

B

.bss

由符号开始的块;程序中的数据区域,包含未初始化的数据(通常在可执行文件中不占空间)。

C

缓存

位于 CPU 与主内存之间的高速内存,用于提高系统性能。

控制总线

CPU 发送的一组电子信号,控制诸如读写操作和生成等待状态等活动。

D

数据总线

CPU 发送的一组电子信号,用于在 CPU 与外部设备(如内存或 I/O)之间传输数据。

默认 NaN 启用(DN)

当默认 NaN 启用时(FPSCR[25]),任何产生 NaN 结果的操作都会返回默认的 NaN 值。

非规格化数

指数为 0 且没有隐含 HO 尾数位的浮点数。非规格化数比规范化数精度低,但替代方法是遇到下溢时将值设置为 0。

E

有效地址(EA)

为寻址模式计算的最终内存地址(通常涉及加法、减法和位移操作)。

等式

一个汇编指令,关联一个值与符号名称。

F

外观代码

调用函数时周围的代码,用于修改该函数的行为(例如调整输入输出值、检查范围限制和其他类似操作)。

标志

表示系统状态的布尔变量。通常,这个术语与 PSTATE 寄存器中的条件码标志相关。

浮点状态和控制寄存器

FPSCR 包含由浮点操作设置的状态标志,并包含控制各种浮点指令操作的状态标志。

浮点单元(FPU)

(某些 CPU 上可选)负责计算浮点算术操作的硬件。

帧指针

用于访问激活记录中的参数、本地变量和其他项的特殊寄存器。

G

Gas

GNU 汇编器。

通用寄存器

特殊的内存单元,供用户应用程序访问,用于 ARM CPU 上的大多数整数和地址计算。

H

高级语言(HLL)

允许开发人员创建与底层机器架构无关的软件的编程语言。

高位(HO)

最重要。

I

习语

指令或操作的特点。例如,将二进制数左移一位等同于乘以 2。

输入/输出(I/O)

提供给 CPU 的来自外部源的数据(输入),或由 CPU 呈现给外部世界的数据(输出)。

集成开发环境(IDE)

通常由两个或多个程序组合成一个单一工具。至少,IDE 会包括文本编辑器和编译器。大多数 IDE 还包括调试器、构建(make)系统和其他文件管理工具。

L

后进先出(LIFO)

一种访问机制,其中最后插入到列表中的对象将是第一个从列表中移除的对象。CPU 堆栈使用这种机制来保存和恢复数据以及返回地址。

ld

链接器程序(加载程序)。

词法作用域符号

仅在定义它们的特定块内可见的符号。例如,在高级语言(HLL)中,在函数或过程内定义的符号仅对该函数/过程可见,外部不可见。

链接寄存器(LR)

保存bl指令的返回地址,以便目标(bl)函数或过程可以返回到调用者。

低位(LO)

最不重要。

M

机器代码

每个汇编语言指令的二进制指令编码。

显式常量

程序中的符号名称,会被与之关联的值替换。

内存管理单元(MMU)

CPU 中控制内存访问(保护)并重新映射内存地址的硬件(通常是为了允许安全的多任务处理)。

N

自然边界

对象的地址是该对象大小的倍数。

非易失性寄存器

必须在函数调用之间保持的寄存器。

非数值(NaN)

一种特殊的浮点值,表示浮点运算中的非法结果(除了无穷大以外)。

O

操作系统(OS)

控制计算机操作的软件,例如调度任务、执行应用程序并提供对 I/O 设备的访问。

操作码(opcode)

编码机器指令的数字值。

P

位置独立可执行文件(PIE)

可以在内存的任何地址执行而无需修改的代码(重定位)。

处理器状态寄存器(PSTATE)

一种特殊的寄存器,保存条件码标志、中断屏蔽位和其他杂项处理器控制位。

程序计数器

一种特殊的寄存器,保存当前执行指令的内存地址。

程序计数器相对寻址(PC-relative)

基于当前指令的偏移量的内存地址。

R

精简指令集计算机(RISC)

读作“精简指令集计算机”(RISC),而不是“精简指令集计算机”。在 RISC 机器中,CPU 设计者创建尽可能少的指令,以简化实现这些指令所需的硬件。从理论上讲,这使得设计者能够以更低的成本创建更快的 CPU(尽管英特尔的 x86 系列证明了非 RISC CPU 也可以具备这些特性)。

寄存器

直接集成在 CPU 中并且大多数机器指令可访问的专用内存组件。

S

单指令,多数据(SIMD)

SIMD 指令同时对多个数据进行操作。例如,SIMD 加法指令可以并行执行多达 16 次加法运算。尽管 SIMD 指令比单指令单数据指令(例如典型的 ARM 汇编指令)更难使用,但它们有可能大幅加速应用程序的执行。

栈指针寄存器(SP)

一个特殊寄存器,用于引用内存中的硬件栈。

次正常

查看 非规范化数字。

系统总线

由地址、数据和控制总线组成的电子信号集合。

T

标记

一串对编译器有特殊意义的字符。例如运算符、保留字、标识符、字面常量和其他标点符号。

跳板

查看 薄膜。

V

薄膜

一种特殊的代码序列,用于扩展控制转移指令的范围,超出指令正常位移的限制;也称为跳板

易失性寄存器

不需要在函数调用之间保留的寄存器。

W

WZR

32 位零寄存器。

X

XZR

64 位零寄存器。

第十九章:C 安装和使用 GAS

要在你的计算机上编译 ARM 汇编语言源文件(包括本书中的文件),你需要安装 Gas 汇编器和其他工具。汇编器是 GNU C 编译器套件的一部分,因此如果你在系统上安装了该软件包,你也会获得汇编器。由于不同操作系统的安装步骤不同,本附录将帮助你在你的机器上找到并安装所需的文件。

在尝试安装任何软件之前,请使用以下命令检查该软件是否已安装:

as --version
gcc --help

如果这些命令打印出 Gas 和 GCC 的帮助信息,说明汇编器(和 GCC)已经在你的系统上安装好了,你可以继续进行。然而,如果其中任何一条命令提示文件未找到(或没有打印帮助信息),请跳到针对你操作系统的章节来安装 GCC 和 Gas。

C.1 macOS

要在 Apple Silicon 机器(如 Mx 类机器,甚至 iPad 或 iPhone)上编写汇编代码,你必须在 Mac 上安装 Apple 的 Xcode 开发平台。前往 Apple App Store,然后定位、下载并安装 Xcode。这是一个非常大的文件(多个 GB),下载需要一些时间。运行安装程序后,你应该能够编译本书中的汇编代码。

从技术上讲,当安装 Xcode 时,你实际上是在安装 Apple 的 LLVM Clang 编译器和 Clang 汇编器,而不是 GCC 和 Gas。然而,这些工具与 GCC 和 Gas 大多数是兼容的(并且完全能够处理本书中的所有源代码)。这两个汇编器有时会有语法差异,aoaa.inc 文件就是为了帮助弥合这些差异而创建的(如果你有兴趣查看一些差异,可以查看 aoaa.inc 的源代码)。本书中的各种评论也指出了 Gas 和 Clang 汇编器之间的差异(例如,参见第 3.8 节“获取内存对象的地址”,在 第 153 页有讨论 lea 宏)。

C.2 Linux

许多 Linux 安装已经预装了 GCC 和 Gas。如果你使用的是 Debian/Ubuntu Linux 或 Raspberry Pi OS,在命令行中输入 gcc --version;如果 shell 没有报错而是打印出 GCC 的信息,说明一切就绪。

如果你确实需要安装 GCC 和 Gas,第一步与任何 Linux 安装一样(本书假设使用 Debian/Ubuntu 安装),是更新你的系统:

sudo apt update

请注意,这需要具有 sudo 权限的账户。

接下来,使用以下命令安装 build-essential 包:

sudo apt install build-essential

这将安装多个程序,包括 gcc、Gas、make 和 ld。通过以下命令验证系统是否已正确安装这些工具:

gcc --version

如果 GCC 已正确安装,这应会打印出其版本信息。

可选地,你可以通过以下命令安装 GCC 和其他工具的手册页:

sudo apt-get install manpages-dev

如果你使用的是其他版本的 Linux,请查阅你所在发行版的文档或在线搜索安装说明来安装 GCC。

第二十章:D THE BASH SHELL INTERPRETER

Bourne-again shell,也称为 Bourne shellbash,是一个 Unix shell 解释器。Bash 是著名的 Unix sh(shell)程序的升级版,后者是 Unix System 7 中的默认 shell 程序。

Bash 是 Linux 系统中典型的 shell(尽管还有其他 shell,如 zsh 和 csh)。大多数 Linux 和 macOS shell 程序在进行简单命令行操作时大致兼容,但它们在支持复杂 shell 编程方面有所不同。

本文中的所有编程示例通过 bash 命令行运行 GCC 和 Gas 汇编器。因此,你至少需要具备一些基本的 bash 知识,以便理解本书中的基本命令。本附录提供了使用 bash 的说明,包括对其常用命令的描述,但这部分内容同样适用于其他 shell 解释器。

为了避免必须提及特定的操作系统或发行版名称,我将在本附录中使用 Unix 这个名称来指代底层系统(在撰写本文时,Unix 是 The Open Group 的注册商标)。

D.1 运行 Bash

bash shell 解释器是一个类似于其他 Unix 应用程序的应用程序。要使用它,你必须首先执行 bash 应用程序。在基于文本的 Unix 系统中,在你登录系统后,会运行某种 shell 应用程序。你可以设置你的系统,使其自动运行 bash(或任何其他 shell 程序)。

在基于 GUI 的 Linux 系统或 macOS 系统中,通常需要运行终端程序来启动一个 shell 解释器。在这两种情况下,通常在 shell 应用程序运行时会呈现一个 命令提示符。根据你是否以普通用户或 root 用户身份登录,提示符通常为 $ 或 #;某些 shell 显示 % 提示符。在打印出命令提示符后,shell 将等待你输入命令。

此时,你正在运行一个 shell 解释器,尽管它可能不是 bash;它可能是标准的 sh shell 或其他 shell(例如,macOS 的 终端 应用程序运行 zsh)。尽管大多数 shell 的行为大致相同,但为了确保你正在运行 bash,请在命令提示符后输入以下行(然后按 ENTER):

$ bash

这将确保你正在运行 bash 应用程序,这样本附录中的所有注释都将适用。你可以通过在命令行中执行退出命令来终止这个 bash 实例并返回到原始 shell。

D.2 命令行

前面的示例(bash)是给 shell 解释器的命令实例。命令 由在命令提示符后输入的一行文本组成(通常通过键盘输入),并采取以下形式:

`commandName optionalCommandLineArguments optionalRedirection`

这一整行代码被称为命令行,它由三个组件组成:一个命令(通常是一个单词,例如前面的 bash),后跟可选的命令行参数,最后是可选的重定向或管道操作数。

命令是内置 bash 命令的名称,或者是可执行应用程序(或 shell 脚本)的名称。例如,命令可能是你刚刚创建的汇编语言源文件的名称。

D.2.1 命令行参数

命令行参数是由空格或制表符分隔的字符字符串,bash 会将这些字符串传递给应用程序。这些命令行参数的确切语法依赖于应用程序。有些简单的应用程序(包括本书中的汇编语言示例)可能完全忽略任何命令行参数;而其他应用程序可能需要非常特定的参数,如果语法不正确,则会报告错误。

一些应用程序定义了两种类型的命令行参数:选项和参数。历史上,命令行选项由一个连字符(-)前缀跟随一个单字符,或者由一个双连字符(--)前缀跟随一系列字符。例如,bash 命令支持该选项。

bash --help

它显示帮助信息并终止,而不会运行 bash 解释器。

相比之下,一个实际的命令行参数通常是一个文件名或其他应用程序将作为输入值使用的单词(或字符串)。考虑以下命令行:

bash script

在这个例子中,bash 将启动一个第二实例,并从脚本文件中读取一组命令(每行一个),然后像键盘输入一样执行这些命令。出现在第 38 页的构建脚本文件是一个很好的 shell 脚本示例。

由于 bash 使用空格或制表符来分隔命令行参数,如果你想指定一个包含这些定界符(在命令行中分隔项目的字符)的单一参数,就会出现问题。幸运的是,bash 提供了一种语法,允许你在命令行中包含这些定界符:如果你用引号括起命令行参数,bash 会将引号内的所有内容作为一个单一的命令行参数传递给应用程序。

例如,如果一个命令需要一个文件名,而你希望使用的文件名包含空格,你可以像下面这样将该文件名传递给命令:

`command` "`filename with spaces`"

Bash 不会将引号包含为它传递给命令的参数的一部分。如果你需要将引号字符作为命令行参数的一部分传递,请在引号前加上反斜杠字符(\)。例如,考虑以下命令:

`command` "`argument containing` \"`quotes`\""

这样将包含“引号”的参数作为一个单一参数传递给命令。

正如你将在本附录后面看到的,你还可以用单引号(撇号字符)括起命令行参数。这两者的区别在于变量扩展。

D.2.2 重定向和管道参数

Bash 程序(以及类 Unix 操作系统)提供了标准输入设备、标准输出设备和标准错误设备。

标准输入设备通常是控制台键盘。如果一个程序从标准输入设备读取数据,程序将会暂停,直到用户从键盘输入一行文本。

标准输出设备是控制台显示器。如果一个应用程序将数据写入标准输出设备,系统会将其显示在屏幕上。标准错误设备也默认是控制台显示器,因此写入标准错误设备的数据也会显示在屏幕上。

Bash shell 提供了通过在命令行使用特殊参数来重定向输入和输出的能力。I/O 重定向通常允许你指定一个文件名。当重定向标准输入设备时,应用程序将从文件中读取文本行(而不是从键盘)。当重定向标准输出时,应用程序将数据写入文本文件,而不是写入控制台显示器。

要从文件重定向标准输入,请使用如下命令行参数:

`command` <`InputFile`

其中 InputFile 是一个包含将被应用程序读取的文本的文件名。每当应用程序命令通常会从键盘读取一行文本时,它将从指定的文件中读取该行文本。

要将标准输出重定向到文件,请使用以下命令行语法:

`command` >`OutputFile`

任何通常写入标准输出设备(显示器)的输出将会写入指定的文件(OutputFile)。该语法会删除任何名为OutputFile的现有文件内容,并将其内容替换为命令应用程序的输出。

一种输出重定向的变体是将程序的输出附加到现有文件的末尾,而不是替换其内容。要以这种方式使用输出重定向,请使用以下语法:

`command` >>`OutputFile`

请注意,重定向标准输出设备不会改变标准错误输出设备。如果你已经将标准输出重定向到一个文件,并且某个应用程序将数据写入标准错误设备,那么该输出仍然会显示在控制台上。你可以使用以下语法来重定向标准错误设备:

`command` 2>`ErrorOutput`

2>告诉 bash 将输出重定向到文件句柄 2。 在类 Unix 系统中,句柄 0 保留用于标准输入,句柄 1 保留用于标准输出,而句柄 2 保留用于标准错误输出设备。将句柄号放在>符号前面,指定要重定向的输出。

如果你愿意,你也可以使用这种语法来重定向标准输出:

`command` 1>`OutputFile`

最终形式的输入/输出重定向是管道,它将一个应用程序的标准输出连接到第二个应用程序的标准输入。这允许第二个应用程序将第一个应用程序的输出作为输入进行读取。以下是管道重定向的语法:

`command1` `OptionalCommand1Arguments` | `command2` `OptionalCommand2Arguments`

这告诉 bash 将命令 1 的输出重定向为命令 2 的输入。

D.3 目录、路径名和文件名

当你运行 bash 时,它会默认指向操作系统文件结构中的当前目录。Unix 称此为 当前工作目录。每当你运行 bash(例如,当你第一次登录时),当前工作目录通常是你的主目录(由操作系统决定)。例如,在我的 Debian 系统中,这是 /home/rhyde(在 macOS 下,它是 /Users/rhyde)。

当你在命令行中指定的文件名不包含任何斜杠字符时,bash 或应用程序会假设该文件存在于系统提供的可执行路径中。路径名 是由一个或多个目录名组成的序列,目录名之间用斜杠分隔,最后是文件名。相对路径名以目录名开始;系统会在当前工作目录中查找该目录。例如,dir1/dir2/filename 指定了当前工作目录下的 dir1 目录中的 dir2 中的文件 (filename)。绝对路径名 以斜杠开始,后面跟着最外层的根目录。例如,/home/rhyde/x.txt 指定了在 /home/rhyde 目录中的 x.txt 文件(这是我在 Debian 下的主目录)。

波浪号特殊字符 (~) 是当前用户主目录的简写。因此,~/x.txt 是我在 Debian 系统中指定 /home/rhyde/x.txt 的另一种方式。这个方案在 macOS 中也适用,因此它是以系统独立的方式指定用户目录路径的一个有用方法。

特殊字符句点 (.) 本身是当前工作目录的简写。双句点序列 (..) 是指当前工作目录的上级目录(父目录)。例如,../x.txt 指的是父目录中的名为 x.txt 的文件。在基于 Linux 的系统中,要从当前工作目录执行应用程序,必须指定 ./filename,而不能仅仅使用 filename(除非你已经将 ./ 添加到执行路径中)。

一些 Unix 命令允许你在命令行中指定多个文件名。这些命令通常允许使用通配符字符来指定多个名称。Unix 在指定通配符时支持一套丰富的正则表达式,其中你最常使用的是星号 (*)。Bash 将匹配星号所代表的任意数量的字符(零个或多个)。因此,文件名 *.txt 将匹配以四个字符序列 .txt 结尾的任何文件(包括仅为 .txt 的文件)。

D.4 内置和外部 Bash 命令

Bash 支持两种类型的命令:内建命令和外部命令。内建命令作为 bash 应用程序的一部分存在;一个位于 bash 源代码中的函数处理给定的内建命令。外部命令是指与 bash 分开存在的可执行程序,bash 会加载并执行这些程序(然后在这些程序终止后重新接管控制)。内建命令在您运行 bash 时始终可用,但外部命令可能可用,也可能不可用,这取决于这些命令的可执行代码是否存在。除非另有说明,您可以假设以下小节中出现的命令都是外部命令。

本书中的汇编语言示例程序是外部命令的示例。当您输入类似的命令时:

./Listing1-5

在命令行中,bash 会在当前工作目录 (./) 中找到 Listing1-5 可执行文件并尝试执行该代码。

出于安全原因,bash 不会自动执行当前工作目录中的程序,除非您明确地在可执行文件名前加上 ./ 字符。bash 假设没有显式路径信息的程序名可以在 执行路径 中找到。执行路径(见第 D.6.1 节,“定义 Shell 脚本变量和值”,在第 961 页)是一个目录列表,bash 会在这些目录中查找您指定的可执行程序,而无需显式的路径信息。通常,bash 会在 /bin/usr/bin/sbin 等地方查找可执行程序。

为了使 bash 能够从当前工作目录执行程序,而无需在可执行文件名前加上 ./ 字符,您可以将 ./ 添加到执行路径中。然而,出于安全原因,不建议这么做。有关更多信息,请参阅第 D.8 节,“更多信息”,在第 968 页。

D.5 基本 Unix 命令

在本附录中描述所有 Unix 命令是不可能的。这将单独需要一本大书。本节描述了对开发汇编语言程序有用的几个命令,以及它们的一些选项和参数。有关其他 bash 命令的信息,请查看第 D.8 节,“更多信息”,在第 968 页。

D.5.1 man

如果您知道命令名称,但不确定其命令行参数和选项的语法,可以使用 man 命令来了解它。此命令会显示一个(支持的)命令的手册页面,语法如下:

man `CommandName`

其中 CommandName 是您想要阅读其手册页面的命令名称。例如,下面的命令会显示 man 命令本身的手册页面:

man man

您可以使用 man 命令,配合以下小节中列出的命令名称,来了解每个命令的更多信息。

D.5.2 cd 或 chdir

你可以使用 cd(改变目录)命令设置当前工作目录(chdir 是此命令的别名)。标准语法是

cd `DirectoryPath`

其中 DirectoryPath 是文件系统中某个目录的相对或绝对路径。如果目录不存在或指定的是文件而不是目录,Unix 会报告错误。

如果你不提供任何参数而指定 cd 命令,它会切换到当前用户的主目录。这等同于在命令行中输入以下命令:

cd ~

cd 和 chdir 命令是内建在 bash 中的。

D.5.3 pwd

pwd(打印工作目录)命令打印当前工作目录的路径。Bash 通常会在命令行提示符中显示当前工作目录;如果你的系统是这种配置,可能不需要使用 pwd。这个命令也是一个内建的 bash 命令。

D.5.4 ls

ls(列出目录)命令将目录列表打印到标准输出。没有选项时,它会显示当前目录的内容。

当打印目录列表到显示器时,ls 默认使用多列格式。如果你将输出重定向到文件,或者通过管道使用,命令会以单列格式打印列表。

如果你提供一个目录路径作为参数,ls 命令会显示指定目录的内容(假设该目录存在)。如果你提供一个文件的路径作为参数,ls 命令会只显示该文件名(同样,假设文件在指定路径下存在)。

默认情况下,ls 命令不会显示以句点开头的文件名。Unix 将这些文件视为隐藏文件。如果你想显示这些文件名,可以使用 -a 命令行选项:

ls -a

默认情况下,ls 命令只列出指定目录中的文件名和目录名。如果你指定了 -l(长格式)选项,ls 命令将显示每个文件的更多信息:

$ ls -l
total 3256
-rw-r--r--@ 1 rhyde  staff   168089 Dec 29  20`xx` encoder.pdf
-rw-r--r--@ 1 rhyde  staff  1492096 Dec 27  20`xx` mcp23017.png

列表中的第一列指定文件权限。接下来的三列提供链接计数和所有权信息,接着是文件大小和修改日期与时间,最后是文件名。

D.5.5 file

与 macOS 和 Windows 不同,Unix 不会将特定数据类型与文件关联。你可以使用 Unix 的 file 命令来确定某个文件的类型:

file `pathname`

file 命令会根据指定路径的文件类型做出最佳猜测并返回结果。

D.5.6 cat, less, more, 和 tail

要查看文本文件的内容,可以使用 cat(连接)命令完整显示该文件内容。

cat `pathname`

其中 pathname 是你希望显示的文件的路径名。

cat命令的问题在于,它试图一次性将整个文件写入显示器。许多文件的大小超出了屏幕一次性显示的范围,因此cat最终只会显示文件的最后几行;此外,非常大的文件可能需要一段时间才能显示其内容。如果你希望能够逐屏翻阅文件,可以使用moreless命令:

more `pathname`
less `pathname`

more命令现在已经过时,但仍然可以处理包含该命令的旧脚本文件。它一次显示一屏的文本,并允许你按行(按 ENTER 键)或按页(按空格键)滚动文件。more的一个大缺点是你只能向前查看文件;当信息滚动出屏幕后,它就丢失了。

less命令(其名称来自短语less is more)是more的升级版本,允许你在一页中向前和向后滚动。大多数人使用less命令而不是more,因为它有更多的功能(比如能够使用箭头键来持续按行上下滚动)。

如果你只想查看大型文件的最后几行,可以使用tail命令:

tail `pathname`

默认情况下,tail会打印文件的最后 10 行。你可以使用-n xxxx命令行选项,其中xxxx是一个十进制数值,用来指定不同的行数。例如

tail -n 20 x.txt

显示文件x.txt.的最后 20 行

D.5.7 mv

mv(移动)命令的语法如下:

mv `SourcePath DestinationPath`

SourcePath 是你想要移动或重命名的文件的路径,DestinationPath 是你希望文件移动到的最终目标路径(或你希望用于文件的新名称)。

要在当前目录中重命名文件,mv命令的形式为

mv `OldName NewName`

其中,OldName 是你想要更改的现有文件名,NewName 是你想要为文件重命名的新文件名。这两个都是简单的文件名(没有目录路径成分)。请注意,NewName 必须与 OldName 不同。

要将文件从一个目录移动到另一个目录,SourcePath 或 DestinationPath(或两者)必须包含目录成分。SourcePath 必须在路径的末尾包含文件名成分(即要移动的文件名)。对于 DestinationPath,末尾的文件名是可选的。如果 DestinationPath 是一个目录的名称(而不是文件),mv将把源文件移动到目标目录,并使用与原始源文件相同的文件名。如果 DestinationPath 末尾有文件名,那么mv会在移动文件时更改文件名。

你可以使用通配符字符与mv命令一起使用,但须遵守以下限制:通配符字符只能出现在源路径中,目标路径必须是一个目录,而不能是实际的文件名。

D.5.8 cp

cp命令的语法如下:

cp `SourcePath DestinationPath`

此命令将指定的 SourcePath 文件复制,并使用 DestinationPath 作为复制文件的名称。如果两个路径名都是简单的文件名(即你正在复制当前目录中的文件),则两个文件名必须不同。

cp 命令接受源操作数中的通配符字符。如果存在通配符字符,则目标必须是目录路径。cp 命令将把所有匹配通配符的文件复制到指定的目录中。

如果源和目标操作数都指定了目录,请使用 -R(递归)命令行选项。这将把源目录中的所有文件复制到目标目录中同名的目录(如果目标目录中尚不存在该目录,则会创建新目录);它还将递归地将源目录中的任何子目录复制到目标目录中同名的子目录中。

D.5.9 rm

rm 命令从目录中移除(删除)一个文件,使用以下语法:

rm `pathname`

pathname 参数必须是指向单个文件的路径,而不是目录。要删除目录及其中的所有文件,请使用以下命令:

rm -R `DirectoryPath`

这将递归地删除 DirectoryPath 中的所有文件和子目录,然后删除由 DirectoryPath 指定的目录。

要删除目录中的所有文件而不删除目录本身,请使用以下命令:

rm -R `DirectoryPath/*`

使用 rm 命令时要非常小心通配符字符。根据当前工作目录,以下命令可能会删除存储设备上的所有内容:

rm -R *

还有一个 rmdir 命令可以用来删除空目录。然而,rm -R directory 命令更容易用于这个目的。

D.5.10 mkdir

mkdir 命令创建一个新的(空的)目录,使用以下语法:

mkdir `DirectoryPath`

其中 DirectoryPath 指定一个尚不存在的目录的路径名。如果 DirectoryPath 是一个实际的路径名,则路径中的所有子目录名称必须存在,路径中的最后一个目录名称(在最后的 / 之后)不能存在。如果指定的是简单的目录名(没有路径),bash 将在当前工作目录中创建该目录。

mkdir 命令支持 -p 命令行选项,可以创建路径中所有不存在的目录。

D.5.11 date

date 命令显示当前日期和时间。你也可以使用此命令来设置 Unix 实时时钟。运行 man date 获取详细信息。

D.5.12 echo

echo 命令将命令行其余部分的文本(由 bash 扩展)打印到标准输出设备。例如:

echo hello, world!

将会把 hello, world! 写入标准输出。你将在脚本中或用来显示各种 shell 变量的值时最常使用此命令。

D.5.13 chmod

尽管 Unix 文件没有具体类型,但目录确实会维护文件是否可以由文件所有者、与文件相关联的组或任何人读取、写入或执行(标准的 Unix 权限)。chmod 命令允许你为特定文件设置(或清除)权限模式位。

chmod 的基本语法是

chmod `options pathname`

其中 pathname 是你想要更改模式的文件路径,options 参数指定新的权限。

options 参数可以是一个八进制(基数 8)数字(通常为三位数),也可以是一个特殊字符串来设置权限。Unix 具有三类权限:所有者/用户、组和其他。所有者类别适用于最初创建文件的用户。类别适用于用户所属的任何组(以及其他用户可能所属的组)。其他类别适用于所有其他人。

除了这三类权限外,Unix 还具有三种主要的权限类型:读取文件的权限、写入数据到文件的权限(或删除文件),以及执行文件的权限(通常适用于目标代码或 Shell 脚本)。

一个典型的 chmod 选项由一个到三个字符组成,字符来自 {ugo} 集合,后面跟着一个加号或减号字符(+ 或 -,而不是 ±),再跟着一个来自 {rwx} 集合的单一字符。例如,u+r 启用用户的读取权限,u+x 启用执行权限,ugo-x 移除所有类别的执行权限。注意,ls -l 命令会列出给定文件的用户、组和其他权限。

你也可以通过指定一个三位八进制数字来设置三类权限,其中每一位代表用户(高位数字)、组(中间数字)和其他(低位数字)的 rwx 权限。例如,755 表示用户具有读/写/执行权限(111[2] = 7[8]),组和其他用户具有读和执行权限(101[2] = 5[8])。注意,755 是你通常会赋予一个公开可用的脚本文件的典型权限。

D.6 Shell 脚本

Shell 脚本是一个文本文件,bash 将其解释为一系列要执行的命令,就像在运行 bash 时每行命令是从键盘输入的一样。对于有微软 Windows 使用经验的人来说,这类似于批处理文件。本节讨论了如何使用 shell 变量和数值、使用内置的特殊 shell 变量,以及如何创建你自己的 bash Shell 脚本。

bash 解释器是一个完整的编程语言,支持条件判断和循环结构,以及命令行上命令的顺序执行。它支持 if...elif...else 语句、一个 case 语句(类似于 C 的 switch 语句),以及各种循环(如 while、for 等)。它还支持函数、本地变量以及其他通常在高级语言中发现的特性。详细讨论这些内容超出了本书的范围。有关更多信息,请参阅 D.8 节中的 Ryan’s Tutorials,第 968 页,以获取本节未涵盖的详细信息。

D.6.1 定义 Shell 脚本变量和值

Bash 允许你定义 shell 变量。一个shell 变量是一个名称(类似于编程语言中的标识符),你可以为其赋值一些文本。例如,以下 bash 命令将文本 ls 赋值给 list:

list=ls

你可以通过在变量名前加上 $ 符号来告诉 bash 扩展 shell 变量名为其关联的文本。例如

$list

展开为

ls

这将显示当前目录列表。

通常情况下,你不会使用 shell 变量来创建现有命令的别名,因为 alias 命令更适合这个工作。相反,你会使用 shell 变量来跟踪路径、选项以及命令行中常用的其他信息。

Bash 提供了多个预定义的 shell 变量,包括以下内容:

$HOME    包含当前用户的主目录路径

$HOSTNAME    包含机器的名称

$PATH    包含一系列由冒号(:)分隔的目录路径,bash 在查找外部命令的可执行文件时会遍历这些路径

$PS1    包含 bash 将作为命令行提示符打印的字符串

$PWD    包含当前工作目录

有关预定义 shell 变量的完整列表,以及特别是关于 $PS1 变量的更多详细信息,请参阅 D.8 节,“更多信息”,见 第 968 页。

你为 shell 变量赋的值将在当前 bash shell 执行期间保持不变。通常,在执行 shell 脚本时,会启动一个新的 bash shell,并且在该执行过程中创建或修改的任何变量值会在该 shell 终止时丢失。为避免这个问题,可以使用内建的 export 命令:

export `variable name`=`value`

此命令将使变量赋值对父 shell 可见。通常情况下,你必须在赋值时使用 export,特别是当你希望在 shell 脚本执行完后保留变量值时。

你可以像在命令行上交互输入命令时一样,在脚本文件中定义 shell 变量。然而,如前所述,任何在 shell 脚本中定义的变量值会在 shell 终止时丢失。这是因为 bash 在启动脚本时会复制执行环境(包括所有 shell 变量的值)。你所做的任何更改或新增内容,例如创建新变量或修改现有变量,只会影响环境的副本。当脚本终止时,它会删除该副本并恢复原始环境。export 命令会告诉 bash 将变量赋值导出到父环境(同时适用于当前的本地环境)。

分配给 shell 变量的值通常被当作文本处理。因为 bash 解释器通过空格或其他分隔符来分割命令行,所以你为脚本变量分配的文本值必须由一个单一的单词(即被分隔符包围的字符序列)组成。如果你希望在值中包含分隔符(以及其他)字符,必须使用引号或撇号将文本值括起来,如以下示例所示:

value1="`Value containing delimiters (spaces)`"
value2='`Another value with delimiters`'

Bash 会扩展双引号(")内的文本,并且会保持单引号(')内的文本不变。考虑以下示例:

aVariable="`Some text`"
value3="aVariable=$aVariable"
value4='aVariable=$aVariable'
echo $value3
echo $value4

执行此序列将产生以下输出:

aVariable=Some Text
aVariable=$aVariable

Bash 会在双引号内扩展 $aVariable,但不会在单引号内扩展它。

你可能会看到被重音符号(`)包围的字符串,在 Unix 中通常被称为反引号。最初,这样的字符串用于包围一个命令,shell 会执行该命令,然后将程序的文本输出替换回反引号包围的字符串。这个语法在现代的 shell 中已被弃用。要捕获命令的输出并将其赋值给变量,请使用 $(command),如以下示例所示:

dirListing=$(ls)

这会创建一个包含当前工作目录列表的字符串,并将该字符串赋值给变量 dirListing。### D.6.2 定义特殊 Shell 变量

除了脚本从父环境继承的 shell 变量外,bash 还定义了某些可能在你编写的 shell 脚本中有用的特殊 shell 变量。这些特殊变量以 $ 开头,通常与传递给脚本的命令行参数相关(参见 表 D-1)。

表 D-1:特殊 Shell 变量

变量 描述
$0 扩展为 shell 脚本文件的路径名。
$1 到 $n 扩展为第一个、第二个、...,第 n 个命令行参数。
$# 扩展为指定参数个数的十进制数字。
$* | 扩展为包含所有命令行参数的字符串。通常用于将参数传递给另一个命令。要将此命令行参数列表字符串赋值给 shell 变量,请使用 $* 来捕获文本作为单个字符串。
$@ | 类似于 $*,不过这个变体会将每个参数用引号括起来。如果原始参数可能已经被引用,并且可能包含空格或其他分隔符字符,这种方式特别有用。最好也以 $@ 的形式调用它。

有关这些功能的更多详细信息,请参见第 D.8 节“更多信息”中的参考资料,第 968 页。

D.6.3 编写你自己的 Shell 脚本

考虑以下来自名为lsPix的文件中的文本:

cd $HOME/Pictures
ls

如果你通过以下命令执行这个 shell 脚本,bash 会切换到用户主目录下的Pictures子目录,显示目录列表,然后将控制权返回给 bash:

bash lsPix

如果你常常执行某些 shell 脚本,在脚本前加上 bash 执行它可能会变得令人烦恼。幸运的是,Unix(通过 sh、bash 或 zsh 等 shell)提供了一种机制,可以直接将 shell 脚本作为命令来执行:使脚本文件可执行。你可以使用 chmod 命令来完成这个操作:

chmod 755 lsPix

这会将所有者的权限设置为 RWX(可读、可写和可执行),将组成员和其他用户的权限设置为 R-X(可读和可执行)。

请注意,build 脚本(本书中使用的)已通过 chmod 777 build 命令使其可执行(这允许所有人修改文件)。因此,你只需在命令行开头输入 ./build,而不是 bash build。

在使 bash shell 脚本可执行时,还需要在脚本文件的开头添加以下语句:

#! /bin/bash

shebang(#!)序列告诉 bash 这是一个 shell 脚本,并提供要执行此命令的 shell 解释器的路径。(在这种情况下,解释器将是 bash,但如果你想的话,也可以指定其他的 shell 解释器,如 /bin/sh。)如果你不知道 bash 解释器的路径,可以执行 Unix 命令 which bash 来打印所需的路径。在第一行包含 shebang 还允许文件 lspix 命令将文件识别为 shell 脚本,而不是简单的 ASCII 文本文件。

一旦你将这一行添加到lsPix并使文件可执行,你只需在命令行中输入以下内容来执行脚本:

./lsPix

字符通常用于在 shell 脚本中创建注释。除了第一行的 shebang 外,bash 解释器会忽略从 # 符号到行尾的所有文本。

重要的是要理解,shell 脚本在它们自己的 bash 副本中执行。因此,它们对 bash 环境所做的任何更改——例如使用 cd 命令设置当前工作目录或更改 shell 变量的值——在脚本终止时都会丢失。例如,当 lsPix 脚本终止时,当前工作目录将返回到原始目录;它不会是 \(HOME/Pictures*(除非在执行 *lsPix* 之前它就已经是 *\)HOME/Pictures)。

D.7 构建脚本

Shell 脚本对于自动化手动操作非常有用。例如,本书使用 build 脚本来组装/编译大部分示例程序。以下是 build 脚本,并将描述其工作原理;我将逐节解释完整的脚本。

像任何好的 shell 脚本一样,构建脚本以定义要使用的 shell 解释器(此例中为 bash)的 shebang 开头:

#!/bin/bash
#
# build
#
# Automatically builds an Art of ARM Assembly
# example program from the command line.
#
# Usage:
#
#   build {options} fileName
#
# (no suffix on the filename.)
#
# options:
#
#   -c: Assemble .S file to object code only.
#   -pie: On Linux, generate a PIE executable.
fileName=""
compileOnly=" "
pie="-no-pie"
cFile="c.cpp"
lib=" "

脚本还定义了几个变量(filename、compileOnly、pie、cFile 和 lib),它们将用于在汇编和编译源文件时指定 GCC 命令行选项。

脚本的下一个部分处理构建命令行中的命令行参数:

❶ while [[$# -gt 0]]
do
    key="$1"
    case $key in
        -c)
        compileOnly='-c'
      ❷ shift
        ;;
        -pie)
        pie='-pie'
        shift
        ;;
        -math)
        math='-lm'
        shift
        ;;
        *)
        fileName="$1"
        shift
        ;;
    esac
done

while 循环逐个处理每个命令行参数 ❶。布尔表达式 \(# -gt 0 会在有一个或多个命令行参数时返回 true(\)# 是参数的数量)。

循环体将关键的局部变量设置为第一个命令行参数($1)的值。接着它执行一个 case 语句,将该参数与选项 -c、-pie 和 -math 进行比较。如果参数匹配其中之一,脚本会将适当的局部变量设置为表示这些选项存在的值。如果 case 表达式不匹配任何选项,默认的 case (*) 会将文件名变量设置为命令行参数的值。

在每个 case 语句结束时,你会注意到一个 shift 语句 ❷。这个语句将所有命令行参数向左移一位(删除原来的 $1 参数),将 $1 = $2、$2 = \(3,依此类推,并将参数计数 (\)#) 减少 1。这为 while 循环准备了下一次迭代,处理剩下的命令行参数。

下一个部分设置了脚本中作为 gcc 命令行的一部分扩展的 objectFile 变量:

# If -c option was provided, assemble only the .S
# file and produce a .o output file.
#
# If -c not specified, compile both c.cpp and the .S
# file and produce an executable:
if ["$compileOnly" = '-c']; then
    objectFile="-o $fileName".o
    cFile=" "
else
    objectFile="-o $fileName"
fi

这段代码将 objectFile 设置为一个字符串,该字符串会在 gcc 命令行中指定一个目标文件名。如果没有 -c 选项,该代码会将 cFile 设置为空字符串,这样 gcc 命令就不会编译 c.cpp(默认情况)。

以下 build 脚本部分会删除该命令可能创建的任何现有目标文件或可执行文件:

# If the executable already exists, delete it:
if test -e "$fileName"; then
    rm "$fileName"
fi
# If the object file already exists, delete it:
if test -e "$fileName".o; then
    rm "$fileName".o
fi

test 内建函数在指定文件存在时返回 true。因此,如果对象文件和可执行文件已经存在,这些 if 语句将删除它们。

接下来,aoaa.inc 头文件需要定义 isLinux 或 isMacOS 符号,以便确定操作系统。定义这些符号后,aoaa.inc 能够选择操作系统特定的代码,从而使示例代码能够在这两个操作系统上(便捷地)编译。为了避免强制用户手动定义该符号,build 脚本在调用 GCC 时会自动定义其中一个符号。为此,build 使用 uname 命令,它返回操作系统内核的名称:

# Determine what OS you're running under (Linux or Darwin [macOS]) and
# issue the appropriate GCC command to compile/assemble the files:
unamestr=$(uname)

在 Linux 下,uname 返回字符串 Linux;在 macOS 下,它返回字符串 Darwin。

最后,build 脚本根据操作系统的适当命令行参数调用 GCC 编译器:

if ["$unamestr" = 'Linux']; then
    gcc -D isLinux=1 $pie $compileOnly $objectFile  $cFile $fileName.S $math
elif ["$unamestr" = 'Darwin']; then
    gcc -D isMacOS=1  $compileOnly $objectFile $cFile  $fileName.S -lSystem $math
fi

注意命令行选项 -D name=1,它根据需要定义 isLinux 或 isMacOS 符号。还要注意,pie(位置无关代码)选项仅在 Linux 下编译时出现,因为 macOS 的代码总是位置无关的。

如果你有需求,修改 build 脚本以添加更多功能是非常容易的。例如,脚本的一个限制是它只允许你指定单个汇编语言源文件(如果指定两个或更多名称,它只会使用你指定的最后一个名称)。你可以通过在 case 语句中的三处修改来改变这一点。

第一个修改是将文件名附加并为 fileName 变量添加 .S 后缀,而不是替换其值。你还必须将可执行输出文件名设置为命令行上指定的第一个汇编文件:

*)
    if[fileName = ""]
    then
        objectFile = "$1"
    fi
    fileName="$filename $1.S"
    shift
    ;;

接下来的修改是在指定编译模式时,将 objectFile 设置为空字符串:

if ["$compileOnly" = '-c']; then
cFile=" "
else
    objectFile="-o $fileName"
fi

原始代码将其设置为指定的文件名;然而,这在仅编译模式下是默认设置,而在汇编多个源文件时,指定单个对象名称会存在问题。

最后的修改是修改两个 gcc 命令行,将汇编文件名中的 .S 后缀去掉(因为这已经在 case 语句中添加了):

if ["$unamestr" = 'Linux']; then
    gcc -D isLinux=1 $pie $compileOnly $objectFile  $cFile $fileName $math
elif ["$unamestr" = 'Darwin']; then
    gcc -D isMacOS=1  $compileOnly $objectFile $cFile  $fileName -lSystem $math
fi

然而,如果你打算使用多个源文件进行复杂的汇编,可能还是使用 makefile 比使用 shell 脚本更合适。

D.8 更多信息

以下是一些描述如何编写 bash 脚本的网站:

第二十一章:E 有用的 C 语言函数

本附录包含了来自 C 标准库(以及 Unix 系统库)的几个 C 函数的列表,这些函数可能对汇编语言程序员有用。

这些函数在 macOS 中的变体使用一个以下划线开头的外部名称。例如,在 macOS 中,strlen() 变成了 _strlen() 函数。aoaa.inc 头文件包含了许多这些函数名称的 #define 声明,在未修饰的名称前添加了下划线前缀:#define strlen _strlen。

E.1 字符串函数

本书的各个章节介绍了许多 C 标准库字符串函数(在 strings.h 头文件中声明)。本节描述了大多数可用的函数,包括本书未使用的那些:

char *strcat(char *dest, const char *src);

将 X1 (src) 指向的以零结尾的字符串连接到 X0 (dest) 指向的字符串的末尾。返回 X0 中指向 dest 字符串的指针。

char *strchr(const char *str, int c);

在由 str (X0) 指向的字符串中搜索字符 c (X1) 的第一次出现。返回指向字符串中找到该字符的位置的指针(在 X0 中),如果 c 在 str 中不存在,则返回 NULL (0) 指针。

char *strcpy(char *dest, const char *src);

将 src (X1) 指向的字符串复制到 dest (X0),包括零终止字节。返回指向 dest 的指针(在 X0 中)。

char *strdup(char *str);

在堆上复制一个字符串。传入时,X0 包含要复制的字符串的指针。返回时,X0 包含指向在堆上分配的字符串副本的指针。当应用程序使用完该字符串后,应该调用 C 标准库的 free() 函数将存储空间归还给堆。虽然 strdup() 在 C 标准库中未定义,但大多数系统在其库中包含了它。

char *strncat(char *dest, const char *src, size_t n);

将 X1 (src) 指向的以零结尾的字符串的最多 n 个字符连接到 X0 (dest) 指向的字符串的末尾,并加上一个零终止字节。返回 X0 中指向 dest 字符串的指针。如果 src 的长度小于 n,则该字符串只会将 src 中的前 n 个字符复制到 dest(加上一个零终止字节)。

char *strpbrk(const char *str1, const char *str2);

查找字符串 str1(由 X0 传入)中第一个与 str2(由 X1 传入)中任意字符匹配的字符。返回指向 str1 中匹配字符的指针(如果没有匹配,则返回 NULL)。

char *strrchr(const char *str, int c);

在由参数 str (传入 X0) 指向的字符串中搜索字符 c(在 X1 中传递)的最后一次出现。返回指向 str 中找到该字符的位置的指针(在 X0 中)。如果字符在 str 中未找到,则此函数在 X0 中返回 NULL (0)。

char *strstr(const char *inStr, const char *search4);

在 inStr(传入 X0)中搜索字符串 search4(传入 X1)的第一个出现位置。如果在 inStr 中找不到 search4 字符串,则返回 NULL(0)。

char *strtok(char *str, char *delim);

将字符串 str(传入 X0)按照分隔符 delim(传入 X1)中的字符分割成一系列标记(单词)。在第一次调用时,函数期望将 C 字符串作为 str 的参数,其第一个字符用作扫描标记的起始位置。在后续调用中,函数期望一个空指针(NULL,0),并将上一个标记的末尾位置作为新的扫描起始位置(跳过任何前导分隔符字符)。每次调用返回一个指向字符串中下一个标记的指针(在 X0 中)。当字符串中的所有标记耗尽时,此函数返回 NULL。

此函数修改 str(X0 指向的字符串)的内容。如果您的程序不能容忍此操作,请在调用 strtok() 前复制 str。strtok() 函数在静态变量中维护内部状态,因此不适合在多线程应用程序中使用。

int memcmp(void *mem1, void *mem2, size_t n);

比较 mem1(传入 X0)和 mem2(传入 X1)的前 n 个字节。其操作类似于 strcmp(),但此函数在遇到 0 字节时不会结束比较;相比之下,strcmp()会根据比较结果返回负值、0 或正值。

int strcasecmp(const char *str1, const char *str2);

比较 str1(X0 指向的字符串)与 str2(X1 指向的字符串)的内容,采用不区分大小写的比较。如果 str1 < str2,则返回(在 X0 中)一个负数;如果 str1 == str2,则返回 0;如果 str1 > str2,则返回一个正数。尽管 strcasecmp() 在 C 标准库中未定义,但许多系统在其库中包含它;有些系统使用 strcmpi() 或 stricmp() 作为函数名。

int strcmp(const char *str1, const char *str2);

比较 str1(X0 指向的字符串)和 str2(X1 指向的字符串)的内容,并返回(在 X0 中)一个负数、0 或正数。如果 str1 < str2,则返回一个负数;如果 str1 == str2,则返回 0;如果 str1 > str2,则返回一个正数。

int strncmp(char *str1, char *str2, size_t n);

比较两个字符串的前 n 个字符,或者直到遇到第一个零终止字节(在任一字符串中)。指针 str1 和 str2 分别传入 X0 和 X1,n 传入 X2。如果字符串通过前 n 个字符相等(或者两个字符串都相等且长度小于 n),则返回 0。如果 str1 小于 str2,则返回一个负数。如果 str1 大于 str2,则返回一个正数。您可以使用此函数通过设置 n 为 str1 的长度来判断 str1 是否是 str2 的前缀。

size_t strcspn(const char *str1, const char *str2);

计算 str1(由 X0 传递)的初始段的长度,该段只包含不在 str2(由 X1 传递)中的字符。返回此计数值保存在 X0 中。

size_t strlen(char *str);

计算零终止字符串的长度。进入时,X0 包含指向字符串的指针,该函数返回字符串的长度(不包括零终止字节)。

size_t strspn(const char *str1, const char *str2);

计算 str1(由 X0 传递)的初始段的长度,该段只包含在 str2(由 X1 传递)中出现的字符。返回此计数值保存在 X0 中。

strlwr(str);

将字符串中的所有字符转换为小写字母。进入时,X0 包含指向待转换字符串的指针;返回时,X0 指向同一字符串,且其中的大写字母已被转换为小写字母。虽然 strlwr()在 C 标准库中没有定义,但许多系统在其库中包含了该函数。

strncpy(char *dest, const char *src, size_t n);

从 src(由 X1 传递)复制最多 n 个字符(由 X2 传递)到 dest(由 X0 传递)。如果 n 小于或等于 src 的长度,则此函数不会复制零终止字节,调用者需要负责添加该额外字节。此函数有两个主要用途。首先,它可以防止覆盖 dest 末尾之后的数据(当 n 包含 dest 缓冲区的大小,再加 1 时,即 X0 指向的位置)。其次,它作为一个子字符串函数,允许从字符串中的特定位置提取 n 个字符。

strupr(str);

将字符串中的所有小写字母转换为大写字母。进入时,X0 包含指向待转换字符串的指针;返回时,X0 指向同一字符串,且其中的小写字母已被转换为大写字母。虽然 strupr()在 C 标准库中没有定义,但许多系统在其库中包含了该函数。

void *memchr(void *mem, int c, size_t n);

在 mem(由 X0 传递)指向的内存块的前 n(由 X2 传递)字节中搜索字符 c(无符号字符,由 X1 传递)的第一次出现。与 strchr()非常相似,不同之处在于此函数在遇到零字节时不会停止扫描。返回 X0 中,找到字符的位置的指针,如果字符 c 在 mem 中不存在,则返回 NULL(0)。

void *memcpy(void *dest, const void *src, size_t n);

从 src 复制 n 字节到 dest(分别由 X2、X1 和 X0 传递)。返回指向 dest 的指针,保存在 X0 中。如果 dest 定义的内存块与 src 定义的内存块重叠,结果是未定义的。

void *memmove(void *dest, const void *src, size_t n);

从 src 复制 n 字节到 dest(分别由 X2、X1 和 X0 传递)。返回指向 dest 的指针,保存在 X0 中。

memmove()函数正确处理源和目标内存块重叠的情况。然而,由于该函数可能比 memcpy()稍慢,因此只有在无法保证内存块不重叠时,才应使用此函数。

void *memset(void *mem, int c, size_t n);

将传入 X1 的 c 的 LO 字节复制到由参数 mem(传入 X0)指向的内存块的前 n(传入 X2)字节中。返回指向该内存块的指针,存放在 X0 中。

E.2 其他 C 标准库和 Unix 函数

本附录中介绍的字符串函数只是 C 标准库中众多函数的一小部分。其他有用的函数包括 POSIX 文件 I/O 函数(在 fcntl.hunistd.h 头文件中声明)、数学库(在 math.h 中找到)等。有关这些头文件的更多信息,请参见以下链接:

fcntl.h

<wbr>pubs<wbr>.opengroup<wbr>.org<wbr>/onlinepubs<wbr>/000095399<wbr>/basedefs<wbr>/fcntl<wbr>.h<wbr>.html

math.h

<wbr>pubs<wbr>.opengroup<wbr>.org<wbr>/onlinepubs<wbr>/9699919799<wbr>/basedefs<wbr>/math<wbr>.h<wbr>.html

unistd.h

<wbr>pubs<wbr>.opengroup<wbr>.org<wbr>/onlinepubs<wbr>/007908775<wbr>/xsh<wbr>/unistd<wbr>.h<wbr>.html

你可以通过指定函数名称来轻松调用这些函数(在 macOS 中调用函数时不要忘记在函数名前加下划线)。你始终通过使用 Linux 的 ARM ABI 或 macOS 的 macOS ABI 来传递参数和获取函数结果(请记住,macOS 在传递变长参数列表给函数时有所不同,例如 printf())。

第二十二章:F 问题的答案

F.1 第一章

1.  作为

2.  地址、数据和控制

3.  PSTATE 寄存器

4.  (a) 4, (b) 8, (c) 16, (d) 8

5.  64 位

6.  bl

7.  ret

8.  应用程序二进制接口

9.  (a) W0 的低字节, (b) W0 的低半字, (c) W0, (d) X0, (e) X0

10.  X0, X1, X2, 和 X3 寄存器(分别)

F.2 第二章

1.  9 × 10³ + 3 × 10² + 8 × 10¹ + 4 × 10⁰ + 5 × 10 ^(–1) + 7 × 10^(–2) + 6 × 10^(–3)

2.  (a) 10, (b) 12, (c) 7, (d) 9, (e) 3, (f) 15

3.  (a) A, (b) E, (c) B, (d) D, (e) 2, (f) C, (g) CF, (h) 98D1

4.  (a) 0001_0010_1010_1111

(b)  1001_1011_1110_0111

(c)  0100_1010

(d)  0001_0011_0111_1111

(e)  1111_0000_0000_1101

(f)  1011_1110_1010_1101

(g)  0100_1001_0011_1000

5.  (a) 10, (b) 11, (c) 15, (d) 13, (e) 14, (f) 12

6.  (a) 32, (b) 128, (c) 16, (d) 64, (e) 4, (f) 8, (g) 4

7.  (a) 4, (b) 8, (c) 16, (d) 2

8.  (a) 16, (b) 256, (c) 65,536, (d) 2

9.  4

10.  0 到 7

11.  位 0

12.  位 63

13.  (a) 0, (b) 0, (c) 0, (d) 1

14.  (a) 0, (b) 1, (c) 1, (d) 1

15.  (a) 0, (b) 1, (c) 1, (d) 0

16.  与 1 做异或(按位操作,寄存器中所有 1 位)

17.  与

18.  或

19.  非(XOR 也与所有 1 位进行异或)

20.  异或

21.  非(eor 也与所有 1 位进行异或)

22.  1111_1011

23.  0000_0010

24.  (a) 1111_1111b, (c) 1000_0000b, (e) 1000_0001b

25.  neg 指令

26.  (a) 1111_1111_1111_1111

(c)  000_0000_0000_0001

(d)  1111_1111_1111_0000

27.  b (b.al)

28.  标签:

29.  负标志(N),零标志(Z),进位标志(C),溢出标志(V)

30.  Z = 1

31.  C = 0 和 Z = 0

32.  bhi, bhs, bls, blo, beq, 和 bne 条件跳转指令

33.  bgt, bge, blt, ble, beq, 和 bne 条件跳转指令

34.  lsl 指令不会影响零标志位。

35.  乘以 2

36.  除以 2

37.  乘法和除法

38.  规范化的浮点值在高阶尾数位置有一个 1 位。

39.  7 位

40.  0x30 到 0x39

41.  撇号(或单引号)字符

F.3 第三章

1.  PC 64 位寄存器

2.  操作码,机器指令的数字编码

3.  静态/标量变量和基于内存的常量

4.  大约 ±1 MB,使用 ldr 和 str 指令

5.  访问内存位置的地址

6.  (b) X0 和 (d) SP

7.  lea 宏(或 adr 和 adrp 指令)

8.  完成所有寻址模式计算后得到的最终地址

9.  使用 .align 3 指令将变量在 .data 区段对齐到 8 字节边界。

10.  内存管理单元

11.  计算内存对象(静态)运行时地址的算术表达式

12.  大端值将值的高位部分存储在较低的内存地址中,而小端值将值的低位部分存储在较低的内存地址中。

13.  rev32 指令

14.  rev16 指令

15.  rev 指令

16.  从 SP 减去 16,然后将值存储到 X0 中,存储位置由 SP 指向。

17.  从 SP 指向的地址加载 X0,然后将 16 加到 SP 寄存器。

18.  反转

19.  后进先出

F.4 第四章

1.  一个常量的符号名称,汇编程序(或预处理器)将在汇编过程中用该常量的数值等效值替换

2.  使用 .equ.set= 指令。如果你的源文件名以 .S 结尾,你还可以使用 C 预处理器(CPP)的 #define 指令。

3.  常量表达式是一个算术表达式,Gas 可以在汇编过程中计算出该表达式的值。你通过计算由逗号分隔的表达式数量来确定字节指令操作数字段中的数据元素数量。

4.  当前在某个段中的偏移量(如 .data.text

5.  句点操作符(.)

6.  将第二个声明的标签减去第一个声明的标签(例如,第二 - 第一)。

7.  一个 64 位内存变量,包含另一个内存对象的地址;你可以使用 .dword 指令为指针分配存储空间(或使用其他机制保留 64 位空间)。

8.  将指针加载到一个 64 位寄存器中,并使用寄存器间接寻址模式访问内存。

9.  使用 .dword 指令。

10.  使用未初始化的指针;使用包含非法值的指针;继续使用已释放的已分配数据(悬空指针);使用完内存后没有释放它(内存泄漏);使用错误的数据类型访问间接数据

11.  指向已分配内存的指针,但该内存已经被释放

12.  内存泄漏发生在程序分配内存(使用 malloc())但在使用完毕后没有释放该存储空间时。

13.  一个由其他数据类型集合构成的对象

14.  一个由零值(通常是字节)分隔的字符序列

15.  一个以长度值开始的字符序列(通常是字节,但也可以是半字、字或其他类型)

16.  描述字符串对象的结构,通常包含长度信息和指向字符串的指针

17.  在连续内存位置中出现的相同类型的对象序列

18.  第一个元素的地址,通常是数组在内存中最低地址的位置

19.  这是一个使用 Gas 的典型数组声明:

anArray .space 256, 0  // 256 bytes, all initialize to 0

20.  你通常会使用像 .word 这样的指令,并列出初始元素值;这是一个示例:

initializedArray: .word 1, 2, 3, 4, 5, 6, 7, 8

如果你有一个字节数组,并且每个字节都用相同的值初始化,你也可以使用 .space 指令。

21.  (a) 将索引乘以 8 并将数组 A 的基地址加到这个乘积中;(b) 要访问 W[i, j],使用地址 = base(W) + (i * 8 + j) * 4;(c) 要访问 R[i, j, k],使用地址 = base(R) + ((i * 4) + j) * 6 + k) * 4。

22.  存储数组的内存机制,其中每行的元素出现在连续的内存位置中,行本身则出现在连续的内存块中

23.  在内存中存储数组的机制,其中每一列的元素出现在连续的内存位置中,而列出现在连续的内存块中

24.  一个典型的二维数组声明,例如字数组 W[4,8],形式如下:W: .space 4 * 8 * 4, 0。

25.  一种复合数据类型,其元素(字段)不必都具有相同类型

26.  使用如下语句:

struct student
    byte  sName, 65 // Includes zero-terminating byte
    hword Major
    byte  SSN, 12   // Includes zero-terminating byte
    hword Midterm1
    hword Midterm2
    hword Final
    hword Homework
    hword Projects
ends student

27.  将特定字段的偏移量加到结构的基地址上。

28.  一种结构,其中所有字段占用相同的内存位置

29.  对于结构体,每个字段分配一个独立的内存块(根据其大小),而对于共用体,所有字段分配相同的内存位置。

F.5 第五章

1.  bl 指令将下一条指令的地址复制到 LR 寄存器中,然后将控制转移到操作数指定的目标地址。

2.  ret 指令将 LR 中的值复制到程序计数器。

3.  调用者保存的最大问题是难以维护。它还会生成更大的目标代码文件。

4.  它保存寄存器,占用了宝贵的 CPU 周期,即使调用者不要求保存这些寄存器。

5.  栈中的存储空间,过程在其中维护参数、返回地址、保存的寄存器值、本地变量以及可能的其他数据

6.  FP 寄存器(X29)

7.  标准入口序列如下:

stp fp, lr, [sp, #-16]!   // Save LR and FP values.
mov fp, sp                // Get activation record ptr in FP.
sub sp, sp, #NumVars      // Allocate local storage.

8.  标准退出序列如下所示:

mov sp, fp    // Deallocate storage for all the local vars.
ldp fp, lr, [sp], #16  // Pop FP and return address.
ret                    // Return to caller.

9.  过程在激活记录中自动分配和释放存储的变量

10.  进入过程时

11.  参数的值

12.  参数的地址

13.  X0, X1, X2 和 X3

14.  超过第八个参数的所有参数通过栈传递。

15.  ARM 过程可以使用易失性寄存器而不保存其值;非易失性寄存器的值必须在过程调用间保持。

16.  寄存器 X0, X1, ..., X15

17.  寄存器 X16 到 X31(SP)

18.  过程通过 LR 寄存器中传递的地址访问传递的参数。

19.  大型参数(如数组和记录)应通过引用传递,因为使用引用参数时,过程运行更快且更简短。

20.  X0 寄存器(X8 可以包含指向大型函数返回结果的指针)

21.  传递给过程或函数的调用程序地址,作为参数传递

22.  使用 br 指令调用过程参数(以及通过指针调用任何过程)。

23.  为寄存器预留本地存储空间,并在本地存储中保存这些值。 ## F.6 第六章

1.  cmp 指令会在两个操作数相等时设置零标志。

2.  cmp 指令会在一个无符号操作数(左边)大于或等于另一个无符号操作数(右边)时设置进位标志。

3.  cmp 指令会在左操作数小于右操作数时将负标志和溢出标志设置为相反的值;当左操作数大于或等于右操作数时,它们将被设置为相同的值。

4.  x = x + y:

ldr w0, [fp, #x]
ldr w1, [fp, #y]
add w0, w0, w1
str w0, [fp, #x]

x = y - z:

ldr w0, [fp, #y]
ldr w1, [fp, #z]
sub w0, w0, w1
str w0, [fp, #x]

x = y * z:

ldr w0, [fp, #y]
ldr w1, [fp, #z]
mul w0, w0, w1
str w0, [fp, #x]

x = y + z * t:

ldr w0, [fp, #y]
ldr w1, [fp, #z]
ldr w2, [fp, #t]
mul w1, w1, w2
sub w0, w0, w1
str w0, [fp, #x]

x = (y + z) * t:

ldr w0, [fp, #y]
ldr w1, [fp, #z]
add w0, w0, w1
ldr w1, [fp, #t]
mul w0, w0, w1
str w0, [fp, #x]

x = -((x * y) / z):

ldr  w0, [fp, #x]
ldr  w1, [fp, #y]
mul  w0, w0, w1
ldr  w1, [fp, #z]
sdiv w0, w0, w1
neg  w0, w0
str  w0, fp, #x]

x = (y == z) && (t != 0):

ldr  w0, [fp, #y]
ldr  w1, [fp, #z]
cmp  w0, w1
cset w0, eq
ldr  w1, [fp, #t]
cmp  w1, #0
cset w1, ne
and  w0, w0, w1
str  w0, [fp, #w]

5.  x = x * 2:

ldr w0, [fp, #x]
lsl w0, w0, #1
str w0, [fp, #x]

x = y * 5:

ldr w0, [fp, #y]
lsl w1, w0, #2
add w0, w0, w1
str w0, [fp, #x]

x = y * 8:

ldr w0, [fp, #y]
lsl w0, w0, #3
str w0, [fp, #x]

6.  x = x / 2:

ldr x0, [fp, #x]
lsr x0, #1
str x0, [fp, #x]

x = y / 8:

ldr x0, [fp, #y]
lsr x0, #3
str x0, [fp, #x]

x = z / 10:

ldr x0, [fp, #z]
ldr x1, =6554    // 65,536/10
mul x0, x0, x1
lsr x0, x0, #16  // Divide by 65,535.
str x0, [fp, #x]

7.  x = x + y:

ldr  d0, [fp, #x]
ldr  d1, [fp, #y]
fadd d0, d0, d1
str  d0, [fp, #x]

x = y - z:

ldr  d0, [fp, #y]
ldr  d1, [fp, #z]
fsub d0, d0, d1
str  d0, [fp, #x]

x = y * z:

ldr  d0, [fp, #y]
ldr  d1, [fp, #z]
fmul d0, d0, d1
str  d0, [fp, #x]

x = y + z * t:

ldr  d0, [fp, #y]
ldr  d1, [fp, #z]
ldr  d2, [fp, #t]
fmul d1, d1, d2
fadd d0, d0, d1
str  d0, [fp, #x]

x = (y + z) * t:

ldr  d0, [fp, #y]
ldr  d1, [fp, #z]
fadd d0, d0, d1
ldr  d1, [fp, #t]
fmul d0, d0, d1
str  d0, [fp, #x]

x = -((x * y) / z):

ldr  d0, [fp, #x]
ldr. d1, [fp, #y]
fmul d0, d0, d1
ldr  d1, [fp, #z]
div  d0, d0, d1
fneg d0, d0
str  d0, [fp, #x]

8.  bb = x < y:

ldr  d0, [fp, #x]
ldr  d1, [fp, #y]
fcmp d0, d1
cset x0, lo  // Less than, ordered
strb w0, [fp, #bb]

bb = x >= y && x < z:

ldr  d0, [fp, #x]
ldr  d1, [fp, #y]
fcmp d0, d1
cset x0, ge  // Greater than or equal, ordered (HS is unordered)
ldr  d1, [fp, #z]
fcmp d0, d1
cset x1, lo // Less than, ordered (LT is unordered)
and  x0, x1
strb w0, [fp, #bb]

F.7 第七章

1.  使用 lea 宏来获取程序中符号的地址。

2.  br reg64

3.  一段通过进入和离开某些状态来跟踪执行历史的代码

4.  扩展分支指令范围的机制

5.  短路布尔运算可能不会执行表达式中的所有条件代码,如果它确定结果为真或假而不执行任何额外代码。完全的布尔运算会评估整个表达式,即使在部分评估之后已知结果。

a.

ldr  w0, [fp, #x]
ldr  w1, [fp, #y]
cmp  w0, w1
cset w0, eq
ldr  w1, [fp, #z]
ldr  w2, [fp, #y]
cmp  w0, w1
cset w1, hi
orrs w0, w1
beq  skip

     Do something.
skip:

b.

ldr  w0, [fp, #x]
ldr  w1, [fp, #y]
cmp  w0, w1
cset w0, ne
ldr  w1, [fp, #z]
ldr  w2, [fp, #t]
cmp  w1, w2
cset w1, lo
ands w0, w1
beq  doElse

  `  then statements`
b.al ifDone

doElse:
    `else statements`
ifDone:

a.

ldrsh w0, [fp, #x]
ldrsh w1, [fp, #y]
cmp   w0, w1
bne   skip
ldrsh w1, [fp, #z]
ldrsh w2, [fp, #t]
bge   skip

   Do something.
skip:

b.

ldrsh w0, [fp, #x]
ldrsh w1, [fp, #y]
cmp   w0, w1
beq   doElse
ldrsh w1, [fp, #z]
ldrsh w2, [fp, #t]
cmp   w1, w2
bge   doElse

 then statements
b.al  ifDone

doElse:
    `else statements`
ifDone:

8.  以下 switch 语句(假设所有变量都是无符号 32 位整数)将转换为汇编语言代码:

a.

ldr  x0, [fp, #t]
cmp  x0, #3
bhi  default
adr  x1, jmpTbl
ldr  x0, [x1, x0, lsl #3]
add  x0, x0, x1
br   x0

jmpTbl: .dword case0-jmpTbl, case1-jmpTbl, case2-jmpTbl, case3-jmpTbl

b.

ldr  x0, [fp, #t]
cmp  x0, #2
blo  default
cmp  x0, #6
bhi  default
adr  x1, jmpTbl
ldr  x0, [x1, x0, lsl #3]
add  x0, x0, x1
br   x0

jmpTbl: .dword case2-jmpTbl, default-jmpTbl, case4-jmpTbl
        .dword case5-jmpTbl, case6-jmpTbl

9.  以下 while 循环将转换为相应的汇编代码(假设所有变量为有符号 32 位整数):

a.

whlLp:
    ldr x0, [fp, #i]
    ldr x1, [fp, #j]
    cmp x0, x1
    bgt endWhl

    Code for loop body

    b.al whlLp
endWhl:

b.

do...while:

rptLp:
    `Code for loop body`

    ldr x0, [fp, #i]
    ldr x1, [fp, #j]
    cmp x0, x1
    bne rptLp

c.

 str  wzr [fp, #i]
forLp:
   ldr  x0, [fp, #i]
   cmp  x0, #10
   bge  forDone

   Code for loop body

   ldr  x0, [fp, #i]
   add  x0, x0, #1
   str  x0, [fp, #i]
   b.al forLp
forDone:

F.8 第八章

a.

 ldp  x0, x1, [fp, #y]
 ldp  x2, x3, [fp, #z]
 adds x0, x0, x2
 adc  x1, x1, x3
 stp  x0, x1, [fp, #x]

b.

 ldr  x0, [fp, #y]
 ldr  w1, [fp, #y+8]
 ldr  x2, [fp, #z]
 adds x0, x0, x2
 adc  w1, w1, wzr
 str  x0, [fp, #x]
 str  w1, [fp, #x+8]

c.

 ldr  w0, [fp, #y]
 ldrh w1, [fp, #y+4]
 ldr  w2, [fp, #z]
 ldrh w3, [fp, #z+4]
 adds w0, w0, w2
 adc  w1, w1, w3
 str  w0, [fp, #x]
 strh w1, [fp, #x+4]

a.

 ldp  x0, x1, [fp, #y]
 ldr  x2, [fp, #y+16]
 ldp  x3, x4, [fp, #z]
 ldr  x5, [fp, #z+16]
 subs x0, x0, x3
 sbc  x1, x1, x4
 sbc  x2, x2, x5
 stp  x0, x1, [fp, #x]
 str  x2, [fp, #x+16]

b.

 ldr  x0, [fp, #y]
 ldr  w1, [fp, #y+8]
 ldp  x2, [fp, #z]
 ldr  w3, [fp, #z+8]
 subs x0, x0, x2
 sbc  w1, w1, w3
 str  x0, [fp, #x]
 str  w1, [fp, #x+8]
 ldr     x0, [fp, #y]
            ldr     x1, [fp, #y + 8]
            ldr     x2, [fp, #z]
            ldr     x3, [fp, #z + 8]
// X5:X4 = X0 * X2

            mul     x4, x0, x2
            umulh   x5, x0, x2

// X6:X7 = X1 * X2, then X5 = X5 + X7 (and save carry for later):

            mul     x7, x1, x2
            umulh   x6, x1, x2
            adds    x5, x5, x7

// X7 = X0 * X3, then X5 = X5 + X7 + C (from earlier):

 mul     x7, x0, x3
            adcs    x5, x5, x7
            umulh   x7, x0, x3
            adcs    x6, x6, x7  // Add in carry from adcs earlier.

// X7:X2 = X3 * X1
            mul     x2, x3, x1
            umulh   x7, x3, x1

            adc     x7, x7, xzr  // Add in C from previous adcs.
            adds    x6, x6, x2   // X6 = X6 + X2
            adc     x7, x7, xzr  // Add in carry from adds.

// X7:X6:X5:X4 contains 256-bit result at this point, ignore overflow:
            stp     x4, x5, [fp, #x]   // Save result to location.

4.  转换如下:

a.

 ldp x0, x1, [fp, #x]
 ldp x2, x3, [fp, #y]
 cmp x0, x2
 bne isFalse
 cmp x1, x3
 bne isFalse

 Code

isFalse:

b.

 ldp x0, x1, [fp, #x]
 ldp x2, x3, [fp, #y]
 cmp x1, x3
 bhi isFalse
 blo isTrue
 cmp x1, x3
 bhs isFalse

isTrue:
 `Code`

isFalse:

c.

 ldp x0, x1, [fp, #x]
 ldp x2, x3, [fp, #y]
 cmp x1, x3
 blo isFalse
 bhi isTrue
 cmp x1, x3
 bls isFalse

isTrue:
 `Code`

isFalse:

d.

 ldp x0, x1, [fp, #x]
 ldp x2, x3, [fp, #y]
 cmp x1, x3
 bne isTrue
 cmp x1, x3
 beq isFalse

isTrue:
 `Code`

isFalse:

5.  转换如下:

a.

 ldp  x0, x1, [fp, #x]
 subs x0, xzr, x0
 sbc  x1, xzr, x1
 stp  x0, x1, [fp, #x]

b.

 ldp  x0, x1, [fp, #y]
 subs x0, xzr, x0
 sbc  x1, xzr, x1
 stp  x0, x1, [fp, #x]

6.  转换如下:

a.

 ldp x0, x1, [fp, #y]
 ldp x2, x3, [fp, #z]
 and x0, x0, x2
 and x1, x1, x3
 stp x0, x1, [fp, #x]

b.

 ldp x0, x1, [fp, #y]
 ldp x2, x3, [fp, #z]
 orr x0, x0, x2
 orr x1, x1, x3
 stp x0, x1, [fp, #x]

c.

 ldp x0, x1, [fp, #y]
 ldp x2, x3, [fp, #z]
 eor x0, x0, x2
 eor x1, x1, x3
 stp x0, x1, [fp, #x]

d.

 ldp x0, x1, [fp, #y]
 not x0, x0
 not x1, x1
 stp x0, x1, [fp, #x]

e.

 ldp  x0, x1, [fp, #y] // The easy way
 adds x0, x0, x0
 adc  x1, x1, x1
 stp  x0, x1, [fp, #x]

f.

 ldp x0, x1, [fp, #y]  // The easy way
 ror x2, x1, #1
 and x2, x2, #1 << 63
 lsr x0, x0, #1
 orr x0, x0, x2
 lsr x1, x1, #1
 stp x0, x1, [fp, #x]
 ldp x0, x1, [fp, #y]  // The easy way
 ror x2, x1, #1
 and x2, x2, #1 << 63
 lsr x0, x0, #1
 orr x0, x0, x2
 asr x1, x1, #1
 stp x0, x1, [fp, #x]
 ldp  x0, x1, [fp, #x]  // The easy way
 adcs x0, x0, x0
 adcs x1, x1, x1
 stp  x0, x1, [fp, #x]

F.9 第九章

1.  四位输出数字

2.  调用 qToStr 两次,第一次传入高字节(HO),第二次传入低字节(LO)。

3.  获取输入值并检查是否为负数。如果是,输出一个减号(-)字符并将值取反。无论数值是负数还是非负数,调用无符号转换函数处理其余部分。

4.  u64toSizeStr 函数期望 X0 中传递指向目标缓冲区的指针,X1 中传递要转换为字符串的值,X3 中传递最小字段宽度。

5.  该函数将输出足够的字符以正确表示值。

6.  r64ToStr 函数期望 D0 中传递要转换的浮点值,X0 中传递缓冲区指针,X1 中传递字段宽度,X2 中传递小数点后的位数,X3 的低字节中传递填充字符,X4 中传递最大字符串长度。

7.  一个包含#字符的 fWidth 长度的字符串,如果无法正确格式化输出

8.  D0 包含要转换的值;X0 包含输出缓冲区的地址;X1 包含字段宽度;X2 为填充字符;X3 包含指数数字的个数;X4 为最大字符串宽度。

9.  一个用于开始、结束和分隔输入值的字符

10.  溢出和非法输入字符

F.10 第十章

1.  合法输入值的集合

2.  可能输出值的集合

a.

 // Assume "input" passed in X0.
 lea  x1, f        // Lookup table
 ldrb w0, [x1, x0] // Function result is left in W0.

b.

 // Assume "input" passed in X0.
 lea  x1, f        // Lookup table
 ldrh w0, [x1, x0, uxtw #1] // Function result is left in W0.

c.

// Assume "input" passed in X0.
 lea  x1, f        // Lookup table
 ldrb w0, [x1, x0] // Function result is left in W0.

d.

 // Assume "input" passed in X0.
 lea  x1, f        // Lookup table
 ldr w0, [x1, x0, uxtw #2] // Function result is left in W0.

4.  调整函数输入值的过程,使最小值和最大值受到限制,以便能够使用更小的表格

5.  因为内存访问相较于计算性能非常缓慢

F.11 第十一章

1.  通道是向量寄存器中一个字节、半字、字或双字数组的元素。当对一对向量寄存器进行操作时,通道是两个向量中的对应元素。

2.  标量指令对单个数据项进行操作,而向量指令对向量寄存器中的多个数据项(通道)进行操作。

3.  fmov Sd, Ws 指令

4.  fmov Dd, Xs 指令

5.  tbl 或 tbx 指令

6.  mov Vd.t[index], Rs 指令(Rn = Xn 或 Wn)

7.  shl Vd.2D, Vs.2D, #n 指令

8.  垂直加法将两个向量寄存器中的对应通道相加,而水平加法将一个向量寄存器中的相邻通道相加。

9.  使用 movi v0.16B, #0 指令。

10.  使用 movi v0.16B, #0xff 指令。

F.12 第十二章

1.  and 和 bic 指令

2.  bic 指令

3.  orr 指令

4.  eor 指令

5.  tst 指令

6.  bfxil(或 bfm)指令

7.  bfi(或 bfm)指令

8.  clz 指令

9.  你可以反转寄存器中的位,反转所有位,然后使用 clz 指令找到第一个非零位。

10.  cnt 指令

F.13 第十三章

1.  编译时语言

2.  在汇编过程中(编译时)

3.  #warning

4..warning

5.  #error

6..error

7.  #define

8..equ、.set 和 =

9.  #ifdef, #ifndef, #if, #elif, #else 和 #endif

10.  主要的 Gas 条件汇编指令是 .if、.elseif、.else 和 .endif。次要的汇编指令有 .ifdef、.ifb、.ifc、.ifeq、.ifeqs、.ifge、.ifgt、.ifile、.iflt、.ifnb、.ifnc、.ifndef/.ifnotdef、.ifne、.ifnc 和 .ifnes。

11.  CPP 映射宏

12..rept、.irp、.irpc 和 .endr

13..irpc

14.  #define

15..宏和 .endm

16.  在文件中指令助记符应出现的地方指定宏名称。

17.  使用函数符号。例如:mymacro(p1, p2)。

18.  在指令操作数字段中指定 Gas 宏参数作为操作数。例如:lea x0, label(x0 和 label 是 lea 宏的参数)。

19.  在宏声明中将 :req 放在参数后面。

20.  在宏声明中指定参数名称,不带后缀(:req、:varargs 或 =expression)。默认情况下,Gas 宏参数是可选的。

21.  在 #define 宏定义中,将 ... 用作最后(或唯一)一个参数。

22.  在 Gas 宏定义中,将 :varargs 放在最后(或唯一)一个参数后面。

23.  使用 .ifb (if blank) 条件汇编指令。

24..exitm

F.14 第十四章

1.  内存中以一个包含 0 的字节结尾的零个或多个字符序列

2.  因为程序通常必须扫描整个字符串以确定其长度

3.  因为这种字符串汇编语言类型(a)将字符串的长度作为数据类型的一部分进行编码,(b)将字符串数据对齐到 16 字节边界,并且(c)保证字符串的存储空间是 16 字节的倍数。这允许算法获取字符串末尾之外的额外数据,只要所有数据都适合在对齐到 16 字节边界的 16 字节块内。

4.  因为起始索引参数可以是任何值

5.  因为它们必须处理可变长度的字符

F.15 第十五章

1.  #ifndef 或 .ifndef

2.  一个源文件的汇编加上它直接或间接包含的任何文件

3..global

4..extern。严格来说,使用这个指令是可选的,因为 Gas 假设所有未定义的符号都是外部符号。

`target: dependencies`
    commands

6.  一个依赖于 makefile 的文件是必须构建或更新的文件,以便正确构建当前文件(也就是说,当前文件依赖于该 makefile 依赖的文件才能被构建)。

7.  删除通过 make 操作生成的所有可执行文件和目标代码文件。

8.  链接器可以使用的对象模块集合,以便仅提取它所需要的对象模块

F.16 第十六章

1.  操作系统通常使用 svc 调用操作系统的 API 函数。

2.  #0

3.  #0x80

第二十三章:索引

  • 数字

  • .2byte 指令,17

  • 8 位超出 127 的指数,94

  • .8byte 指令,17

  • 13 位立即数,107

  • 16 位无符号立即数限制,107

  • 16 位值,56

  • 32 位寄存器,11

  • 32 位变量,56–57

  • 64 位寄存器,11

  • 128 位十进制输出(转换为字符串),510

  • 128 位操作

  • 逻辑与,465

  • 取反,467

  • 左移,467–468,709–711

  • 异或,465

  • 128 位值比较,446–450

  • 192 位加法,442

  • 192 位左移操作,469

  • 256 位比较,449

  • 256 位逻辑或操作,466

  • 256 位减法,446

  • A

  • AARCH64,xxviii

  • ABI(应用二进制接口),30–33

  • 绝对差值指令,669–671

  • 绝对值比较,690–691

  • 访问,内存,119,135–137

  • 页面边界,128

  • 违反,181

  • 访问数据

  • MMU 页面末尾,128

  • 指针数据,174

  • 被压入栈中,165–166

  • 访问数组元素,146

  • 列主序数组,210

  • 单维数组,197

  • 浮点计算中的累积误差,324–325

  • 精度,324

  • Acorn RISC 机器,xxvi

  • 激活记录,244–247

  • 运行时构造,244

  • 加法

  • 192 位,442–443

  • 字节和半字,473

  • 不同大小的操作数,472–475

  • 扩展精度,442

  • 混合大小,473

  • 成对操作,664–666

  • 加法指令,28,442–443,659–660

  • 向量加法,647,667

  • 带进位的加法,443

  • 带缩小的加法,663–664

  • 水平加法,665,667–668

  • Neon,647,659–660,666–668

  • 成对操作,665

  • 饱和,667

  • 垂直, 664

  • 地址, 11

  • 对齐, 263

  • 基址, 146

  • 表达式, 149

  • 寻址模式, 140–149

  • 间接加偏移量, 143

  • 对于 Neon 加载和存储指令, 633

  • 后索引, 145

  • 前索引, 144–145

  • 缩放索引, 146–149

  • 缩放间接, 143

  • 地址空间位置随机化(ASLR), 23–25, 128

  • adr 指令, 25, 153

  • 高级 RISC 机器, xxvi

  • .a 文件, 883

  • 聚合数据类型, 186

  • 别名(即指令助记符), 745

  • 寄存器, 22

  • 对齐

  • 地址到某个边界, 263

  • 位串, 705

  • 数据, 138–140

  • 栈, 155

  • 变量, 19–21

  • 选择内存中的对齐方式, 140

  • 对齐指令, 6, 19–21, 139, 263, 780

  • 分配

  • 字符串存储, 803

  • 数据段中的变量, 138

  • 和指令, 61, 704–705

  • Neon, 648

  • 与运算, 58, 648

  • 128 位, 465

  • 真值表, 58

  • aoaa.inc

  • 头文件, 771

  • 包含文件, 10, 26, 36

  • 苹果硅, xxvii

  • 应用程序二进制接口(ABI), 30–33

  • 应用程序编程接口(APIs), 33

  • 架构, CPU, 11

  • args 宏, 779

  • 参数。

  • 算术

  • 使用不同大小的操作数, 472–475

  • 表达式, 303–312

  • 转换为汇编语言, 303

  • 浮点数, 322

  • 无限精度, 323

  • 逻辑系统, 314

  • 混合大小, 472

  • 运算符

  • 在 CPP 表达式中, 745–746

  • 优先级, 308

  • 实数, 322

  • 右移操作, 84

  • 扩展精度, 472

  • SIMD 操作, 659

  • ARM64, xxviii

  • armasm64 工具,xxix–xxx

  • ARM 内存访问,119

  • 应用二进制接口,31

  • 指针,174

  • ARM SVE(可扩展向量扩展),667

  • ARMv8,xxviii

  • 数组,194–212

  • 访问

  • 列主数组元素,210

  • 单维数组元素,195

  • 四维,207

  • 步进遍历数组元素,144

  • 三维,207

  • 二维行主序列,206

  • 数组,207–208

  • 冒泡排序,198–203

  • 列主序列,204,209–210

  • 声明,195

  • 索引,146,195

  • 映射到内存,203–212

  • 多维,203

  • 打包,731

  • 行主序列,204–209

  • 结构体,218

  • ASCII 字符集,55,99–102

  • ASCII 组,100

  • ASLR(地址空间位置随机化),23–25,128

  • asr 指令,321

  • 移位操作符(操作数 2),109

  • 符号扩展,473

  • 汇编/C 混合程序,8

  • 汇编语言

  • 指令,22

  • 编程风格,228–230

  • 源文件

  • 节,6

  • 后缀,4

  • 标准入口序列,248

  • 语句格式,229

  • 字符串类型,802,805

  • 汇编时间

  • 计算字符串长度,189

  • 常量,744

  • 赋值,304

  • 结合性,307–308

  • 自动变量,250

  • B

  • 退格字符,100

  • .balign 指令,21,139

  • b.al(总是跳转)指令,76,357

  • 基地址,146

  • bash shell,xxxi

  • bfm(位域移动)指令,726–728

  • bfxil(位域提取和插入)指令,727

  • 大端数据组织,133

  • 大端到小端转换,134,646

  • 二进制编码十进制(BCD),54,98–99

  • 二进制转换

  • 偶数/奇数—除以二, 47

  • 转换为十六进制, 49–50

  • 二进制数字, 47

  • 二进制分数, 94

  • 二进制逻辑, 46

  • 二进制计数系统, 45–48

  • 二进制点, 94

  • 二进制到十六进制字符串函数, 483

  • b 指令, 75

  • 比特排序, 694

  • 位, 47, 53

  • 数组, 731

  • 清除, 61

  • 位域, 727

  • 向量, 727

  • 归零, 59

  • 合并, 729–731

  • 数据, 703

  • 提取, 704, 713, 715

  • 字段, 85–93, 726–728

  • 首位和末位清除, 704, 734

  • 首位和末位设置, 704, 734

  • 强制, 61

  • 保护, 324

  • 插入

  • 插入到位数组中, 732

  • 将位集分配到另一个位串, 706

  • 位串, 719–726

  • 如果为真, 648

  • 在向量中, 648

  • 反转, 61, 704, 709

  • 操作, 648, 703–704

  • 掩码, 61, 704

  • 最重要和最不重要, 48

  • 移动, 714

  • Neon, 648

  • 编号, 54

  • 偏移, 704

  • 操作, 58–65

  • 打包数组, 731

  • 模式搜索, 736

  • 在 PSTATE 中, 93

  • 反转, 712

  • 运行, 704

  • 散布, 735

  • 搜索, 734, 736

  • 选择, 648

  • 集合, 704

  • 设置, 59, 61, 704

  • 符号, 65

  • 起始位置, 719

  • 测试, 715

  • 字节, 54

  • 指令, 691, 704–706, 710

  • 位串, 704

  • 对齐, 705

  • 数组, 731

  • 合并, 729

  • 分布, 729

  • 提取, 726

  • 插入, 719–726

  • 合并, 735

  • 打包数组, 731

  • 打包和解包, 719

  • 从中散布位, 735

  • 有选择地反转位, 60

  • 检测 1 位, 717

  • 位操作, 60–61

  • 位操作选择指令, 648

  • BMP(基本多语言平面), Unicode, 847

  • Bn 寄存器, 623

  • 布尔常量表示, 313

  • 布尔评估, 319

  • 短路, 380–384

  • 布尔表达式, 313, 319

  • 布尔逻辑系统, 314

  • 布尔值, 53

  • 分支和链接指令, 29–30, 230, 235, 284–285

  • 通过寄存器间接, 235

  • 通过计算避免分支, 388

  • 条件分支, 77–78, 355

  • 无符号, 80

  • 分支指令, 74–82, 357

  • 间接, 358

  • 相反, 82

  • 无条件, 77

  • 汇编语言中的 break 语句, 420–421

  • .bss 段, 124–126

  • 可执行文件中的空间, 125

  • 冒泡排序, 198–203

  • 构建 shell 脚本, 37

  • 总线错误, 155, 286

  • 字节寻址内存, 14

  • .byte 指令, 55

  • 字节宏, 780

  • 字节, 53–55

  • 字节变量声明, 55

  • C

  • 缓存, 16

  • 被调用者和调用者寄存器的保存, 239

  • 调用约定, 258

  • 调用树, 242

  • 典范等价, 849

  • 回车, 100

  • 进位条件码, 14

  • 进位 (C) 标志, 296, 719

  • cmp 后的设置, 296

  • case 标签, 399

  • case 语句, 389

  • -c(仅编译)命令行选项, 38

  • C/C++ 预处理器, 742

  • C/C++ 标准库, 5

  • 调用函数, 33–36

  • aoaa.inc 中的函数名, 774

  • 数学, 347

  • cEL 异常级别, 14

  • 中央处理单元(CPU),11

  • 字符,55,99–103

  • 组合,852

  • 重音字符,849

  • 常量,101

  • 数据,99

  • 分隔符,566

  • 名称,848

  • 字符串,187–194,795

  • char 数据类型,102

  • C 整型,441

  • 清除位,59,61,704

  • 位域,727

  • 向量,648

  • 饱和时裁剪,72

  • cmp 指令,78,295–297

  • cmtst 指令,706

  • 代码缩进,229

  • .code 宏,229,784

  • 代码移动,387

  • 代码点,102,847

  • 代码段,121

  • 在汇编语言程序中,6

  • 代码大小优化,482

  • 代码片段,xxx

  • 列优先顺序,204,209–210

  • 命令行解释器,xxxi

  • 命令行定义,758

  • 常见指针问题,180–186

  • 交换律运算符,311

  • 比较与分支指令,425,715

  • 比较

  • 128 位值,447

  • 256 位,449

  • 绝对值,690–691

  • 日期,92–93

  • 扩展精度,446–447,449

  • 浮点,336–343

  • 有序,97

  • 标量,688,690

  • 字符串,824,829

  • 无序,97,336

  • 向量,687–693

  • 整数,688

  • 编译时语言(CTL),741–742

  • 常量,744

  • 表达式,745

  • 循环,763

  • 补码方法,65

  • 复合算术表达式,307

  • 复合数据类型,186–221

  • 条件汇编,760

  • 条件分支,77–78

  • 有符号与无符号,80

  • 条件编译,746

  • 使用调试和测试代码,748

  • 条件执行,74

  • 条件指令,297–299,711

  • 分支,77,80

  • 比较,299,314

  • 与连接,315–318

  • 或连接,318–319

  • 布尔表达式的编码,314

  • 等同于定义有用的位模式,786

  • cmp 指令后的标志设置,295–296

  • 增量,298

  • 反转,298

  • 取反,298

  • 选择/移动,298,343

  • 设置,299

  • CPP 中的条件宏,756

  • 条件语句,372

  • 条件码,14。另见 标志

  • 定义,317–318

  • 输入条件化,614

  • 条件,298,318

  • 常量表达式,150

  • 常量池,130

  • 常量

  • 声明,21

  • 浮点,97,334

  • 大,111–113

  • 字面量,21,49

  • 清单,21,170

  • 换行,170

  • 只读变量,170

  • 符号化,170

  • 常量值,21

  • 13 位立即数,107

  • 64 位立即数,103

  • 字符字面量,101

  • 浮点,97

  • 十六进制字面量,49

  • 清单,170

  • 换行符(nl),170

  • 符号化,170

  • 使用只读数据作为常量,170

  • .const 指令,122

  • continue 语句,422

  • 控制总线,11

  • 控制字符,100

  • 控制结构,355

  • 控制转移指令,74

  • 转换

  • 128 位十进制输出转字符串,510–516

  • ASCII 数字转换为数值,101

  • 大小写转换,100

  • 二进制

  • 偶数/奇数–除以二,47

  • 转换为十六进制,49–50

  • 将 break 语句转换为纯汇编,421

  • 将 continue 语句转换为纯汇编语言,422

  • 十进制

  • 转换为二进制,47

  • 扩展精度无符号数转字符串,510–516

  • 格式化为字符串,517–528

  • 有符号转字符串,509

  • 字符串转整数,566–578

  • 无符号转字符串,495–509

  • 字节序,134,646

  • 定点,344,684

  • 浮点数,683–686

  • 转换为和从整数,344,683–684

  • 转字符串,529–565

  • 将 forever 语句转换为纯汇编,419

  • 将 for 语句转换为纯汇编,420

  • 半精度到单精度,685

  • 十六进制

  • 转二进制,49

  • 数字转字符,478

  • 转字符串,478–495

  • 字符串转数字,578–587

  • 将 if 语句转换为纯汇编,371

  • 整型

  • 转浮点数,344,683–684

  • 转字符串,509–510

  • 非交换算术运算符转汇编语言,310

  • 数值转 ASCII 数字,101

  • 递归,495

  • 将 repeat...until 语句转换为纯汇编,417

  • 字符串

  • 转浮点数,588–602

  • 转整数,566–587

  • 转数字,566–602

  • 协处理器,327

  • 复制字符串数据,818

  • cos() 函数,347

  • CPP(C/C++ 预处理器),742

  • 算术表达式,745–746

  • 编译时常量,744

  • 条件编译,746

  • 调试和测试代码,748

  • 已定义函数,746

  • 已定义符号,检查,746

  • endif 语句,746

  • error 指令,743

  • 表达式

  • 编译时,745

  • else,746

  • if,746–747

  • 迭代,757

  • 宏参数,749

  • 展开,750

  • 分隔符,750

  • 宏,749

  • 组合,752

  • 条件,756

  • 定义行限制,753

  • eval,758

  • 与 Gas 宏对比,790

  • if_else,756

  • 使用迭代,757

  • 递归,752

  • 重新定义,759

  • 取消定义,759

  • 零参数宏,749

  • 处理 VA_ARGS 参数列表,757

  • 文本连接,754

  • undef 语句,759

  • 可变参数列表,751

  • warning 指令,743

  • 警告与错误,744

  • C 预处理器,8

  • CPU(中央处理单元),11

  • Creative Commons 4.0 许可证,xxxii

  • CTL(编译时语言),742

  • D

  • 悬空指针,182

  • 数据对齐,138–140

  • 数据声明指令,16–17,122

  • 标签字段,18

  • 数据表示,45,169

  • 数据段,122

  • 变量分配,138

  • 数据类型,复合类型,186–221

  • 日期比较,92–93

  • DBCS(双字节字符集),846

  • 十进制转换,47,495–509,566–578

  • 十进制计数系统,46

  • 决策,371

  • 声明

  • 数组,195

  • 字节变量,55

  • 字符变量,102

  • 常量,21

  • 浮点变量,97–98

  • 指针,174–175

  • Gas 中的变量,16–18

  • 解码 ARM 指令,104

  • 在 CPP 中定义的函数,746

  • 确定性循环,419

  • 数据去交织,636,642

  • 分隔符字符,566

  • 非规范化值,94,96,342

  • 字符串描述符,189–190

  • 解构代码,386–388

  • 算术运算中的不同大小操作数,472–475

  • 数字,二进制,47

  • 指令,17–18

  • 对齐,6,19–21,139,263,780

  • .bss,124–126

  • 使用…减小可执行文件大小,125

  • .byte,55

  • .code,229,784

  • .const,122

  • .data,16–17,122

  • else,761

  • 结束,233,761,782–783

  • enter,784

  • 等式,170–171

  • 错误,743,760

  • .exitm,770

  • 外部,864

  • .fill,196

  • 浮点,97–98

  • .global,233,864

  • if,761

  • .include,862

  • 不确定重复,764

  • 离开,784

  • .pool,130–131,334

  • proc,233

  • public,233

  • .purgem,771

  • .rept....endr,763–764

  • .req,22

  • .rodata,122–124,170

  • .section,122–124,126

  • .set,21,170

  • .space,196

  • .struct,217

  • .text,121–122

  • .warning,760

  • wastr,263,783

  • 位移,132

  • 显示错误和警告信息,743

  • 分配位串,729

  • div128 算法,511–516

  • 除法,294,457–465,679–680

  • 扩展精度,457

  • 整数,294

  • 模拟除法,321

  • 无符号,294

  • 向量,679–680

  • FPCR 中的 DN(默认 NaN 启用)位,330

  • Dn寄存器,623

  • 域条件,614

  • 双字节字符集(DBCS),846

  • .double 指令,97

  • 双重加载和存储,155

  • 双重宏,782

  • 双精度浮点声明,17

  • 双字(dwords),53

  • dtoStr(双字到字符串)函数,482

  • dup 指令,631

  • 防止重复包含文件/操作,863

  • .dword 指令,57

  • 双目运算,58

  • 动态链接,369

  • 动态内存分配,178

  • 动态范围,323

  • 动态字符串分配,803

  • FPSR 中的 DZC(除零累计)标志,331,335

  • E

  • 编辑器,4

  • 有效地址(EA),146,153

  • 有效内存地址,143,153

  • 数组中的元素访问,146

  • 列主序,210

  • 单维度,195

  • 单步执行,144

  • else 指令,761

  • else 语句,746

  • 结束指令,233,761,782–783

  • 字节序组织,133–135

  • 字节序转换,134,646

  • 结束宏,782–783

  • 进入宏,784

  • 入口序列,标准,248

  • eor 指令,61,709

  • 排他或非操作,709

  • Neon,648

  • 等式,21

  • 指令,170–171

  • 公共,784

  • 错误指令,743,760

  • 错误

  • 总线,155,286

  • 汇编过程中的消息,743

  • 偶数/奇数除以二的二进制转换,47

  • 排他或(XOR)操作,58–60

  • 128 位,467

  • 向量,648

  • 可执行文件大小,通过 .bss 指令减少,125

  • .exitm 指令,770

  • 演绎,129

  • 指数

  • 偏置,95

  • excess-127,94–95

  • excess-1,023,95

  • 浮点数,95

  • 表达式,307

  • 地址,149

  • 算术,303,307

  • 布尔值,312–319

  • CPP,745–747

  • 在 #if 语句中,747

  • 临时值,311

  • 扩展乘法,672

  • 扩展精度

  • 算术,441

  • 加法,442

  • 除法,457

  • 乘法,450

  • 减法,445–446

  • 比较,446–450

  • 转换

  • 字符串到数字转换,566,578

  • 无符号十进制转字符串,510–516

  • 十六进制输出,494

  • 输入/输出,478

  • 格式化输入/输出,517

  • 否定,465

  • 移位操作,467

  • 算术右移,472

  • 逻辑右移,472

  • 左移,467

  • 扩展运算符,110

  • 操作数 2 扩展运算符,474

  • 外部指令,864

  • 外部符号,6,865

  • 提取指令,643,713,715

  • 提取

  • 位,704,713,715

  • 位串,726–727,735

  • F

  • 虚假表示,313

  • 虚假精度,325

  • fcvt 指令,343–344

  • 字段,213

  • 文件输入/输出(I/O)函数,901,907–915

  • files 库,901

  • .fill 指令,196

  • 首位清零,704,734

  • 首位置位,704,734

  • 固定点转换,344,684

  • 标志,28。另见 条件码

  • 进位(C),296,719

  • cmp 指令对其的影响,295–296

  • 在 FPSR 中,331–332,335

  • 负数(N),295,718

  • 溢出(V),296,719

  • 符号(N),295,718

  • 零(Z),295,716

  • 浮点数

  • 计算,322

  • 累积误差,324

  • 向量乘法,671

  • 比较,336–343

  • 绝对值,690–691

  • Neon,689–691

  • 标量,690

  • 向量,689

  • 条件码标志,331–332,335

  • 常量,97,334

  • 转换,683–686

  • 双精度到单精度,685

  • 与整数之间的转换,344,683

  • 字符串之间的转换,529–565,588–602

  • 数据移动指令,332

  • 声明,97–98

  • 双精度,17

  • 指令,97–98

  • 指数,95

  • 格式,93–96

  • 单精度,17,94–95

  • 立即数指令,630

  • 隐含位,94

  • 无穷大表示,97

  • 规范化,96

  • 操作数,立即数,334

  • 参数,346

  • 寄存器,346

  • 控制,328,330

  • 状态,328

  • 字符串输出,529

  • 下溢,326

  • 隐式位,94

  • 四舍五入,686

  • 非标准,342。另见 非正规值

  • fmov 指令,333

  • 使用立即数操作数,334

  • 强制为 0 的结果,59

  • 强制位为 0 或 1,61

  • 永远/结束循环,418

  • for 循环,419

  • 格式化的十进制转字符串转换,517–528

  • 四维数组访问,207

  • FPU(浮点运算单元),327

  • 数据移动指令,332

  • 帧指针(FP)寄存器,13,246

  • free() 函数,120,178,182

  • 功能宏,749

  • 函数结果,32

  • 函数。参见 过程

  • FZ16(清零,半精度)位于 FPCR,330

  • G

  • Gas,3

  • 字面常量,49

  • 宏,765

  • 与 CPP 宏相比,790

  • 变量,16–18

  • Gas/GCC 混合程序,8

  • GCC,7

  • 一般保护错误,121

  • 通用寄存器,11

  • 在汇编过程中生成错误和警告,760

  • getErrno 宏,785

  • .global 指令,233,864

  • 全局名称,6

  • 全局变量,300

  • 字形,848

  • GNU 汇编器,3

  • goto 宏,785

  • 粒度(MMU 页面),127

  • 字符集,848–849

  • 保护位或数字,324

  • H

  • 半精度到单精度转换,685

  • 半字数据类型(hwords),53,55–56

  • 变量,56

  • 硬件堆栈,155

  • 头文件,863

  • aoaa.inc,771

  • 防止多重包含,772,863

  • 堆,120

  • 字符串存储,803

  • “Hello, world!” 程序,33,40

  • 独立版本,899

  • 十六进制

  • 转换

  • 转换为二进制,49–50

  • 数字转字符,478

  • 转换为字符串,478–495

  • 字符串转数字,578–587

  • 字面常量,49

  • 编号系统,45,48–50,54

  • 输出,扩展精度,494

  • 高级语言(HLL)控制结构,355

  • Hn(16 位半字)寄存器,623

  • 高阶(HO)位,48,65–66,72,495,651

  • 尾数,94,96

  • 高阶字节,56–57,133

  • 在半字中,55–56,112

  • 在一个字中,56–57

  • 在字中的高阶半字,57

  • 高阶半字,55–57,98

  • 在一个字节中,55

  • 在半字中,56

  • 在一个字中,57

  • 水平操作,646

  • 加法,665,667–668

  • 最小和最大值,682

  • hword,780

  • .hword 指令,17,56

  • 混合程序,8

  • I

  • i64toStr 函数,509–510

  • i64toStrSize,522

  • 标识符,6

  • 习语(又名机器特性),319

  • IEEE-754

  • 无穷大表示,97

  • 非数值,97

  • 标准浮动点格式,93–94

  • .if 指令,761

  • if 语句,371

  • 在 C++ 中,746–747

  • if...else,372

  • C++ 宏,756

  • 重新排列表达式以提高性能,385

  • 立即常量

  • 13 位,107

  • 64 位,103

  • 在向量比较中,688–689

  • 和向量寄存器,688–689

  • 立即浮动点操作数,334

  • 隐含位,94

  • 改善循环性能,428

  • 包含指令,862

  • include 与 .include,10,772

  • 包含文件,10

  • aoaa.inc,10,26,36,771

  • 嵌套,862

  • 防止重复,863

  • 包含-或操作,59

  • 条件增量,298

  • 不定重复指令,764

  • 扩展变长参数列表,767

  • 缩进,代码,229

  • 间接分支指令,235,358

  • 间接跳转表,391

  • 间接加偏移寻址模式,143

  • 缩放,143–144

  • 无限循环,415,418

  • 在汇编语言中,419

  • 无限精度算术,323

  • 无限表示,97

  • 输入条件,614

  • 输入/输出(I/O)设备,11

  • 输入/输出程序,901

  • 插入

  • 位集到另一个位串,706

  • 位到位数组,732

  • 向量中的位,648

  • 位串到其他位串,719–726

  • 数据进入向量寄存器的通道,626

  • 指令,626

  • 指令。另见 条件指令

  • 绝对差值,669–671

  • adc,443

  • adcs,443

  • 加,28,442

  • Neon,660

  • addhn,660

  • addhn2,660

  • addp,660

  • adds,28,443

  • addv,647,667

  • adr,25,153

  • adrp,25,153

  • 和,61,704–705

  • Neon,648

  • ands,705

  • asr,84–85,321

  • 汇编语言,22

  • b,75

  • b.al,76,357

  • bcc,77

  • bcs,77

  • beq,77,80

  • bfm,726,728

  • bfxil,727

  • bge,80

  • bgt,80

  • bhi,80

  • bhs,80

  • bic,704

  • Neon,648

  • bif,648

  • 位,648

  • bl, 29, 230, 235

  • ble, 80

  • blo, 80

  • blr, 29, 284

  • bls, 80

  • blt, 80

  • bmi, 77

  • bne, 77, 80

  • bnge, 82

  • bnge (宏), 787

  • bngt, 82

  • bngt (宏), 787

  • bnhi, 82

  • bnhi (宏), 787

  • bnhs, 82

  • bnhs (宏), 787

  • bnle, 82

  • bnle (宏), 787

  • bnlo, 82

  • bnlo (宏), 787

  • bnls, 82

  • bnls (宏), 787

  • bnlt, 82

  • bnlt (宏), 787

  • bpl, 77

  • br, 235, 358

  • 分支, 74–82

  • bsl, 648

  • bvc, 77

  • bvs, 77

  • cbnz, 425, 715

  • cbz, 425, 715

  • ccmn, 299

  • ccmp, 299, 314

  • cmeq, 688

  • cmge, 688

  • cmgt, 688

  • cmhi, 688

  • cmhs, 688

  • cmn, 297

  • cmp, 295

  • cmtst, 706

  • 条件, 297–299, 711

  • csel, 298

  • cset, 298, 711

  • csetm, 298, 711

  • csinc, 298

  • csinv, 298

  • csneg, 298

  • 数据传输, 浮点, 332

  • 解码, 104

  • dup, 631

  • eon, 709

  • eor, 61, 709

  • Neon, 648

  • ext, 643

  • extr, 715

  • fabd, 670

  • facge, 691

  • facgt, 691

  • fadd, 660

  • faddp, 660

  • fcmeq, 689

  • fcmge, 689

  • fcmgt, 689

  • fcsel, 343

  • fcvt, 343

  • fcvtas, 683

  • fcvtau, 684

  • fcvtl, 685

  • fcvtl2, 685

  • fcvtms, 683

  • fcvtmu, 684

  • fcvtn, 685

  • fcvtns, 683

  • fcvtn2, 685

  • fcvtnu, 684

  • fcvtps, 683

  • fcvtpu, 684

  • fcvtxn, 685

  • fcvtxn2, 685

  • fcvtzs, 683

  • fcvtzu, 684

  • fmax, 681

  • fmaxnm, 681

  • fmaxnmp, 682

  • fmaxnmv, 683

  • fmaxp, 682

  • fmaxv, 683

  • fmin, 681

  • fminnm, 681

  • fminnmp, 682

  • fminnmv, 683

  • fminp, 682

  • fminv, 683

  • fmla, 673, 676–678

  • fmls, 673, 677–678

  • fmov, 333

  • fmul, 672, 676, 678

  • fmulx, 672, 677, 678

  • 浮点数数据移动, 332

  • frecps, 679

  • frinta, 686

  • frinti, 686

  • frintm, 686

  • frintn, 686

  • frintp, 686

  • frintx, 686

  • frintz, 686

  • frsqrte, 687

  • frsqrts, 687

  • fsqrt, 687

  • fsub, 668

  • 跳转, 785

  • 插入, 626

  • ld1 到 ld4, 632–638

  • ldnp, 333

  • ldp, 155, 333

  • ldr, 23, 27, 73, 130

  • ldrb, 144, 474

  • ldrh, 144, 474

  • ldrsb, 474

  • ldrsh, 474

  • ldrsw, 474

  • ldur, 144, 332

  • lsl, 82, 320, 714

  • lsr, 83, 321, 714

  • mla, 671, 675

  • mlal2, 676

  • mls, 671, 676

  • mov, 27

  • movn, 113

  • movz, 112

  • mrs, 331, 716

  • msr, 331

  • mul, 211

  • Neon,671,675

  • mvn,61,62,704

  • mvni,630

  • not,61,62

  • Neon,648

  • orn,704

  • Neon,648

  • orr,61,704

  • Neon,648

  • raddhn,660

  • raddhn2,660

  • rbit,712

  • ret,29,230,235

  • rev,135

  • rol,470

  • ror,85,715

  • rsubhn,669

  • rsubhn2,669

  • sabal,670

  • sabal2,670

  • saba,670

  • sabd,669

  • sabdl,670

  • saddalp,660

  • saddl,660

  • saddl2,660

  • saddlp,660,666

  • saddw,660

  • saddw2,660

  • sdiv,457

  • shadd,660

  • shl,649

  • shsub,669

  • smaddl,453

  • smax,681

  • smaxp,682

  • smaxv,683

  • smin,681

  • sminp,682

  • sminv,683

  • smlal,672,676

  • smlal2,672

  • smls,676

  • smlsl,672

  • smlsl2,672,676

  • smnegl,452

  • smsubl,453

  • smul,450

  • smulh,453

  • smull,452

  • Neon,672

  • smull2,672,676

  • sqadd,660

  • sqdmlal,673

  • sqdmlal2,673

  • sqdmlsl,673

  • sqdmlsl2,673

  • sqdmulh,674–675

  • sqdmull,673

  • sqdmull2,673

  • sqrdmulh,674

  • sqsub,668

  • square root,686–687

  • srhadd,660

  • sri,710

  • ssubl,668

  • ssubl2,668

  • ssubw,668

  • ssubw2,669

  • st1 到 st4,632–638

  • stnp,333

  • store, 26, 144, 332–333, 632–638

  • stp, 156, 333

  • str, 23, 332

  • stur, 144, 332

  • sub, 28

  • Neon, 668

  • subhn, 669

  • subhn2, 669

  • subs, 28

  • svc, 892–894

  • sxtb, 474

  • sxth, 474

  • sxtw, 474

  • tbnz, 715

  • tbz, 715

  • trn1 和 trn2, 639

  • tst, 704

  • uaba, 670

  • uabal, 670

  • uabd, 669

  • uabdl, 670

  • uaddalp, 660, 666

  • uabdl2, 670

  • uaddl, 660

  • uaddlp, 660, 665

  • uaddl2, 660

  • uaddw, 660

  • uaddw2, 660

  • ubfiz, 714

  • ubfm, 714

  • ubfx, 713

  • udiv, 457

  • uhadd, 660

  • uhsub, 669

  • umaddl, 453

  • umax, 681

  • umaxp, 682

  • umaxv, 683

  • umin, 681

  • uminp, 682

  • uminv, 683

  • umlal, 672, 676

  • umlal2, 672, 676

  • umlsl, 672

  • umlsl2, 672, 676

  • umnegl, 452

  • umsubl, 453

  • umul, 450

  • umulh, 453

  • umull, 452

  • Neon, 672

  • umull2, 672, 676

  • uqadd, 660

  • uqsub, 668

  • urhadd, 660

  • usubl, 668

  • usubl2, 668

  • usubw, 668

  • usubw2, 668

  • uxtb, 474

  • uxth, 474

  • uxtw, 474

  • uzp1 和 uzp2, 642

  • xor, 704

  • zip1 和 zip2, 641

  • 整数

  • 比较, 688

  • 转换

  • 转换为浮点数, 344, 683–684

  • 转换为字符串, 509–510

  • 除法, 294

  • C 中的类型,441

  • 整数舍入,686

  • 交织加载/存储指令寻址模式,633

  • 交织与解交织

  • 数据,635–636,642

  • 寄存器,639

  • 条件反转,298

  • 位反转,61,704

  • 位集中的项,709

  • 位串中的项,60

  • 在宏内调用另一个宏,752

  • FPSR 中的 IOC(无效操作累积)位,331

  • iSize 函数,517

  • 使用 CPP 宏进行迭代,757

  • itoStrSize 函数,522

  • FPSR 中的 IXC(不精确累积)位,332

  • J

  • 间接跳转,358

  • 跳转表

  • 间接,391

  • 非连续条目,392

  • 稀疏,399–402

  • 向量比较,692

  • K

  • KCS 浮点标准,93

  • L

  • 标签,语句,356

  • 向量寄存器中的通道,625

  • 重排,641

  • 大常量,111–113

  • 大参数对象,273

  • 最后清除的位,704,734

  • 后进先出(LIFO)数据结构,161

  • 最后设置的位,734

  • Latin-1 字符集,849

  • ld(加载器/链接器)程序,7

  • lea 宏,142,153,356

  • 最低有效位,48,54

  • leave 宏,784

  • 左结合运算符,308

  • 左移操作,82

  • 长度前缀字符串,188–189,796

  • 库文件,883

  • 程序大小,886

  • 变量的生命周期,250

  • 换行符,100

  • 动态链接,369

  • 链接寄存器(LR),13,29,235

  • 列表,xxx

  • 字面常量,21

  • 十六进制,49

  • 小端数据组织,133

  • 小端到大端转换,134,646

  • 加载和存储架构,23

  • 加载和存储指令,23,27,73,130,332–334

  • 双精度,155–156

  • 交错寻址模式,633

  • Neon,632–638

  • 将浮动点常量加载到 FPU 寄存器,334

  • LO(低位)位,48

  • 尾数部分,338

  • LO 字节,56,133

  • 在半字中,56

  • 在一个字中,57

  • 本地标签,234–235

  • 本地宏,779

  • 本地变量,250,300

  • 位置计数器,18,125,131

  • . 操作符,132,171,189

  • 逻辑,二进制,46

  • 逻辑运算,58–60,313

  • 位操作,58–65

  • Neon,647

  • 左移,82

  • 右移,84

  • 扩展精度,472

  • 向量,648

  • 逻辑系统,314

  • 在一个字中的 LO 半字,57

  • LO 半字节,55,101

  • 在一个字节中,55

  • 在半字中,56

  • 在一个字中,57

  • 查找表,644

  • 创建,615

  • 循环,415–434

  • 控制变量,416,426

  • 确定性,419

  • 无限,418

  • 性能提升,428

  • 寄存器使用,426

  • 解开,432,763

  • M

  • 机器码编码,103–110

  • 机器惯用语(即特殊习惯),319

  • 机器状态,保存,237

  • 宏,741,765–771。另见 CPP

  • 参数,749

  • 扩展,750

  • 分隔符,750

  • 在其他宏中创建,787

  • 功能,749

  • 气体,765

  • 在其他宏中的调用,752

  • 对立分支,787

  • 参数,765–766

  • 扩展,766

  • 使用字符串常量,768

  • 递归,752,769

  • 写入,787

  • 魔术数字,170

  • makefile,37

  • 语法,876

  • malloc()函数,120,178

  • 和内存对齐,804

  • 清单常量,21,170

  • 操作位,648

  • 在内存中,703–704

  • 在 PSTATE 中,93

  • 尾数,94

  • 掩码位,704

  • 掩蔽,61

  • C 标准库中的数学库,351

  • 矩阵转置,639

  • 最大值,681–683

  • 内存,11

  • 访问,119,135–137

  • 页面边界,128

  • 未对齐,16

  • 违规,181

  • 地址,11,19,143

  • 寻址模式,120,140–149

  • 对齐,804

  • 分配,178,803

  • 字节可寻址,14

  • 选择内存中的变量对齐方式,140

  • free()函数,178,182

  • 内存泄漏,183

  • malloc()函数,178

  • 操作中的位,703

  • 映射数组到,203–212

  • MMU 页面,24,127

  • 访问数据在末尾,128

  • 边界,128

  • 读取内存时的故障,797

  • 粒度,127

  • 组织,120–126

  • 多字节数据组织,133

  • 性能,481

  • 指针问题,180–186

  • 从 16 位 CPU 内存读取,136–137

  • 读操作,15

  • 栈,120,155

  • 从中移除数据,163–165

  • 子系统,14–16

  • 变量,19,299

  • 声明,16–18

  • 写操作,15

  • 内存管理单元,24,127

  • mergeBits 函数,725–726

  • 最小程序,235

  • 最小值,681–683

  • 错误对齐的数据和系统缓存,140

  • 混合大小的算术,472

  • 加法,473

  • MMU(内存管理单元)页,24,127

  • 访问数据的末尾,128

  • 边界,128

  • 读取内存时的故障,797

  • 粒度,127

  • 源代码中的模块,xxx

  • 模数,294–295

  • 单子操作符,60

  • 最重要位,48,54

  • 有条件地移动,298

  • 移动指令,27,62,112–113,331,716

  • Neon,626–630

  • 移动位,714

  • 在寄存器之间移动数据,625–626

  • 内存中的多字节数据组织,133

  • 多维数组,203–212

  • 防止头文件多重包含,772

  • 单行上的多个指令,230

  • 每条语句多行,230

  • 乘法指令,211,450–457

  • 扩展,672

  • 浮点,672

  • 乘加,671

  • 乘减,671

  • Neon,671–678

  • 按十倍倍增寄存器值,320

  • 饱和,673–675

  • 有符号,450

  • 无符号,211,450

  • 向量,671–678

  • 按向量元素,678

  • 向量比较后的多路分支,692

  • N

  • NaN(非数值)值,97,330,335

  • 缩小右移指令,655

  • N(负数/符号)条件码,14

  • 取反,28,465

  • 有条件地,298

  • 扩展精度,465

  • 大值,465

  • 负数(N)标志,295,718

  • Neon 指令,621

  • 绝对差,669–671

  • 加法,659–660,666–668

  • cmtst,706

  • 比较

  • 整数,688

  • 浮点,689–691

  • 标量,688–689

  • 有符号,689

  • 转换

  • 浮点格式之间的转换,685

  • 浮点与整数,683–684

  • 除法,679

  • dup,631

  • 扩展,643

  • 插入,626

  • 载入和存储,632–638

  • 逻辑,648

  • 最小和最大,681–683

  • 移动,626–630

  • 乘法,671–678

  • 浮点数舍入到整数值,686

  • 移位,649,710

  • 平方根,687

  • 减法,668–669

  • 转置,639

  • 解压,642

  • 压缩,641

  • Neon 操作,327

  • 算术,659

  • 逻辑,647

  • 移位,649

  • 嵌套包含文件,862

  • 四位数据类型,54

  • nl(换行)常量,170

  • 非连续跳转表项,392

  • 非易失性寄存器,31,300,346

  • 标准形式,849–850

  • 非数字(NaN)值,97,330,335

  • 非指令,61–62

  • Neon,648

  • NOT 操作,60,648

  • 128 位,467

  • NUL 字符,189,263

  • 空指针引用,121

  • 编号系统,46–54

  • 二进制,45–46

  • 十进制,46

  • 十六进制,45,48–50,54

  • 位置的,46

  • 基数,48

  • 二进制补码,56,65

  • 数字,有符号和无符号,65–70

  • 数字转换,478–602

  • 数字表示,50–53

  • NZCV 寄存器,93

  • O

  • FPSR 中的 OFC(溢出累计)位,331

  • 偏移量,132,704

  • 补码格式,94

  • Operand2,106–110,474

  • 允许的字段,107

  • 编码,106–110

  • 扩展操作符,110

  • 移位运算符,109

  • 操作码,104

  • 操作

  • 算术,659

  • 优先级,308

  • 位,58–65

  • 在不同大小的操作数上,472–475

  • 二元,58

  • 扩展,71

  • 水平,646

  • 逻辑,58–60,313

  • AND,58

  • OR,59

  • NOT,60,648

  • push 和 pop,155–158

  • 在向量上的归约,647

  • 旋转,85

  • 饱和,72

  • 移位,82–84

  • 符号压缩,72

  • 栈,120

  • 字符串,795

  • 补码,66

  • 垂直,646

  • 写内存,15

  • XOR,59–60

  • 运算符

  • @,770

  • .,132,171,189

  • 交换律,311

  • 扩展,110

  • 左结合和右结合,308

  • 单目运算,60

  • 优先级,308

  • 移位(操作数 2),109

  • 对立分支指令,82

  • 宏,787

  • 对立条件定义,318

  • 有序比较,97

  • OR 操作,59

  • 256 位,466

  • 向量,648

  • 操作系统函数调用,892

  • 溢出条件码,14

  • overflow (V) 标志,296,719

  • P

  • .p2align 指令,263

  • 打包数据,85–93

  • 打包位字符串,719

  • 页面边界内存访问,128

  • 页面,MMU,24,127

  • 访问数据的末尾,128

  • 边界,128

  • 读取内存时的故障,797

  • 粒度,127

  • 成对加法,664–666

  • 成对最小值和最大值,681–682

  • 参数

  • 宏中的扩展,766

  • 字符串的,768

  • 浮点,346

  • 大对象作为,273

  • 引用,256

  • 值,255

  • 可变长度,263

  • 可变参数,42

  • 传递参数,32

  • 按引用传递,256

  • 效率,258

  • 按值传递,255

  • PC(程序计数器)寄存器,13

  • PC 相对寻址,24

  • 性能分析器,479

  • 性能提升

  • 循环中的,428

  • 在 if 语句中重新排列表达式,385

  • 向量中数据的排列,645

  • -pie(位置独立可执行文件)命令行选项,38

  • 指针

  • 访问数据,174

  • 在汇编语言中,174–186

  • 常量,141,175

  • 悬挂的,182

  • 声明,175

  • 无效的地址值,181

  • 问题,180–186

  • 转换为字符串,190

  • 类型检查,183

  • 未初始化,180

  • 通配符,182

  • 指针变量,178

  • 池指令,130–131,334

  • .pool 节,142

  • 弹出操作,157–158

  • 位值编号系统,46

  • 位置独立代码,128–130

  • 位置独立可执行文件(PIE),23,128

  • 程序化,284

  • 后索引寻址模式,145

  • 优先级规则,308

  • 算术运算符,308

  • 精度,错误,325

  • 预索引寻址模式,144–145

  • 预处理器,8,742

  • 保留寄存器

  • 被调用者和调用者,239

  • 在循环中,427

  • 栈上的,159

  • 易变的,907

  • printf() 函数,33

  • proc 指令,233

  • 过程,230–234

  • 在 ARM 汇编中,6

  • 调用,230

  • 最小的,235

  • 函数的范围,612

  • 指针,284

  • 性能分析工具,479

  • 程序计数器(PC)寄存器,13

  • 程序计数器相对寻址,24

  • 程序列表,xxx

  • 大规模编程,862

  • 编程语言

  • C,441

  • FORTRAN,405

  • 程序大小和对象/库文件,886

  • PSTATE(处理器状态)寄存器,13

  • 操作,93

  • 公共等式,784

  • 公共符号,233

  • 纯汇编应用,890

  • .purgem 指令,771

  • 推送操作,155–157

  • Q

  • QC(饱和累积)位在 FPSR 中,332

  • Qn 寄存器,624

  • qtoStr 函数,494

  • 四字(qwords),53

  • 快速排序,278

  • 安静的 NaN,335

  • .qword 指令,57,781

  • R

  • 基数,48

  • 函数的范围,612

  • 树莓派,xxvii–xxix,481,488,728

  • rbit 指令,712

  • 读取,内存,15

  • 在奇数地址读取 16 位,137

  • 在 16 位 CPU 上读取一个字节,136

  • 只读数据,120

  • 部分,122–124

  • 只读变量作为常量,170

  • 实数算术,322

  • 倒数,679,687

  • 递归转换,495

  • 递归头文件,863

  • 递归宏

  • CPP,752

  • Gas,769

  • 减少代码大小,482

  • 引用参数,256

  • 寄存器间接跳转指令,358

  • 寄存器,11,14–16

  • 32 位,11

  • 64 位,11

  • 别名,22

  • 浮点,346

  • 帧指针,13,246

  • 通用的,11

  • 交错和解交错,639–642

  • 链接,13,29,235

  • 在之间移动数据,625–626

  • 非易变的,31,300,346

  • NZCV,93

  • 保留,159,427

  • 被调用者和调用者,239

  • 在循环中,427

  • 在栈上,159

  • 易变的,907

  • 程序计数器,13

  • PSTATE,13

  • 特殊用途,11

  • 栈指针,13,146,155

  • 用法和循环,426

  • 作为变量,299

  • 向量,623

  • 易变的,31,300,346

  • Xn,146

  • XZR,146

  • 清零位,727

  • 余数,294–295

  • 从栈中移除数据,163–165

  • repeat...until 循环,417–418

  • 表示,45

  • 布尔常量,313

  • 数字,50–53

  • .rept....endr 语句,763

  • .req 指令,22

  • ret 指令,29,230,235

  • 返回地址,13

  • 反转位,712

  • rev 指令,135

  • 右结合运算符,308

  • 右移,83

  • 算术,84

  • RISC(精简指令集计算机),xxvi

  • .rodata(只读数据)对象

  • 声明与值,170

  • 指令,170

  • 段,122–124

  • 使用 ror 指令模拟 rol 指令,470

  • 旋转操作,85

  • 通过进位左移,468

  • 通过进位右移,472

  • 左旋转,85

  • 向量,710

  • 右旋转,85,715

  • 操作数 2,109

  • 向量,711

  • 四舍五入,345

  • 在浮点计算过程中,324

  • 浮点值,686

  • 积分,686

  • 四舍五入模式控制,330–331

  • 行主序列,204–209

  • 运行汇编语言程序,7–10

  • 连续的 0 位,704

  • 运行时语言,742

  • S

  • salign 宏,780

  • FPSR 中的饱和(QC)位,332

  • 饱和操作,72

  • 乘法和双倍,673–675

  • 左移,650

  • 带有缩小指令的右移,655

  • 向量饱和累加,666

  • 可扩展向量扩展(SVE),667

  • 标量浮点数比较,690

  • 标量指令,621

  • 饱和加法,667

  • 标量,625

  • 缩放索引寻址模式,146–149

  • 缩放间接寻址模式,143

  • 缩放因子,147

  • 变量的作用域,250,865

  • 搜索

  • 对于一位,734

  • 位模式,736

  • 对于第一个或最后一个设置的位,734

  • 在汇编语言源文件中,6–7

  • .bss,124–126

  • 代码, 121

  • .data, 122

  • .pool, 130, 142

  • 只读, 122–124

  • .section 指令, 122–124, 126

  • 标志位, 126

  • .text, 121–122

  • 安全性, 23

  • 段错误, 121, 181

  • 选择位, 648

  • 分离汇编, 866

  • 分离编译, 866

  • .set 指令, 21, 170

  • 设置位, 704

  • 到 0, 59

  • 到 1, 61

  • 共享库, 23

  • shell 解释器, xxxi

  • 移位和插入指令, 652

  • 移位和旋转指令, 704, 709–711

  • 移位操作, 82–85

  • Neon, 649

  • 运算符, 82–83, 109, 320–321, 714

  • 扩展 (操作数 2), 110

  • 左移, 82

  • 128 位, 467–468, 711

  • 192 位, 469

  • 扩展精度, 467

  • 右移, 83–84

  • 累加, 654

  • 算术, 84

  • 扩展精度, 472

  • 收窄, 655

  • 使用 ror 模拟 rol 指令, 470

  • 短路布尔运算, 319, 380

  • 与完全布尔运算的对比, 382

  • .short 指令, 17

  • 副作用, 382

  • 信号 NaN, 335

  • 符号位, 65

  • 符号条件码, 14

  • 有符号比较标志设置, 296

  • 有符号条件跳转, 80

  • 有符号除法, 294

  • 有符号整数转字符串, 509–510

  • 有符号乘法, 450

  • 有符号数, 65–70

  • 补码方法, 65

  • 符号扩展, 110

  • 和收缩, 71–72

  • 使用 asr 的值, 473

  • 符号 (N) 标志, 295, 718

  • 有效数字, 323–324

  • 模拟 div 指令, 321

  • 使用 ror 模拟 rol 指令, 470

  • sin() 函数,347

  • 单维数组访问,195

  • .single 指令,97

  • 单指令,多数据(SIMD)指令,14,58,621

  • 单指令,单数据(SISD)指令,621

  • 单精度浮点格式,94–95

  • 转换,685

  • 声明,17

  • 指数范围,95

  • 精度,95

  • Sn 寄存器,623

  • 排序,198–203

  • 快速排序,278

  • 源代码模块,xxx

  • 源文件

  • 编辑器,4

  • 在汇编过程中合并,862

  • 部分,6–7

  • .S 源文件后缀,4

  • 在汇编语言源文件中,743

  • .space 指令,196

  • 稀疏跳转表,399–402

  • 特殊用途内核模式寄存器,11

  • 平方根指令,686–687

  • 栈指针(SP)寄存器,13,146,155

  • 栈,120,155

  • 访问数据,165–166

  • 对齐,155

  • 清理,163

  • 指针,13

  • 从中移除数据,163–165

  • 临时存储,155

  • 独立的汇编代码,889

  • 独立汇编语言程序,xxxiv

  • 起始位位置,719

  • 状态,机器,405

  • 语句

  • 中断,421

  • 情况,389

  • 条件,372

  • continue,422

  • else,746

  • 如果,371

  • 标签,356

  • repeat...until,417

  • 在同一源代码行上,230

  • 跨多个源代码行,230

  • switch,389

  • while,415

  • 状态变量,405

  • 静态基址(SB)寄存器约定,301

  • 存储指令,26,144,332–333,632–638

  • 双精度,156

  • str.buf 宏,802–803,807

  • 强度削减优化,321

  • 字符串分配

  • 动态,803

  • 堆上, 803

  • str.alloc 函数, 810

  • string.allocPtr 字段, 805

  • str.malloc 函数, 804

  • 字符串比较, 824, 829

  • 宏参数中的字符串扩展, 768

  • 字符串长度, 187

  • 在汇编时计算, 189

  • 函数, 802

  • 长度, 796

  • 零终止字符串, 796

  • 字符串操作, 795

  • 字符串, 189–194, 795

  • 字符, 187–194, 795

  • 复制数据, 818

  • 汇编语言的数据类型, 801–802, 805

  • 函数, 190

  • 长度, 802

  • 标准库, 797–798

  • str.bufInit, 805

  • str.cpy, 818

  • str.free, 814

  • strlen(), 798

  • str.substr, 836

  • strtoh128, 584

  • Unicode, 857

  • 长度前缀, 188–189

  • 指针, 190

  • 存储分配, 803

  • 零终止, 187

  • 存在的问题, 796

  • 字符串到数值的转换

  • 转换为浮点数, 588–602

  • 函数, 566

  • 转换为整数, 566–587

  • str.literal 宏, 803, 807

  • 结构体(结构体), 212–220

  • 数组, 218

  • .struct 指令, 217

  • struct 宏, 214, 779

  • 非正规浮点值, 342. 参见 非规范化值

  • 替换文本, 22

  • 子字符串函数, 836

  • 减法

  • 256 位, 446

  • 扩展精度, 445–446

  • 指令, 28, 668–669

  • 替代码点, 847

  • svc(监督调用)指令, 892–895

  • 字节顺序交换, 134

  • switch 语句, 389–405

  • 默认语句, 396

  • 简单实现中的限制, 393

  • 查找实现, 403

  • 符号常量, 170

  • 符号

  • 检查在 CPP 中是否已定义, 746

  • 外部, 6, 865

  • 公有,233

  • 系统总线,11

  • 系统缓存和非对齐数据,140

  • 系统寄存器名称,93

  • T

  • 表格,605

  • tan() 函数,347

  • 临时值,311

  • 栈上的临时存储,155

  • 测试位指令,704–706,715

  • 测试位,705–706

  • 文本连接,754

  • .text 指令,121

  • 文本编辑器,4

  • .text 区段,6,121–122

  • 常量,130

  • 文本替换,22

  • 三维数组访问,207

  • Thumb 指令集,103

  • 标记,754

  • 跳板,32,369

  • 转移指令,74,144

  • 零扩展和符号扩展,474

  • 将算术表达式翻译为汇编语言,293

  • 转置指令,639

  • 难度较大的编程,319

  • 真值的表示,313

  • 浮点运算中的截断,324,330

  • 真值表,58–60,378

  • 二维行主序数组访问,206

  • 二进制补码计数系统,56

  • 表示法,65

  • 二进制补码操作,66

  • U

  • u64toStr 函数,500

  • u64toStrSize,522

  • u128toStr 函数,511

  • FPSR 中的 UFC(下溢累计)位,332

  • 非对齐内存访问,16

  • 无条件跳转指令,77

  • undef 语句,759

  • 下溢,326

  • Unicode,845

  • 汇编语言中的,853

  • 基本多语言平面,847–848

  • 规范等价,849

  • 字符名,848

  • 字符集,55,102,846

  • 代码点,847

  • 组合字符,852

  • 编码,850

  • 字形,848

  • 规范形式,849–850

  • 规范化,849

  • 字符串函数,857

  • 替代代码点,847

  • Unicode 文本格式,850–851

  • 未初始化的数据段,124

  • 未初始化的指针,180

  • 联合体,220

  • 无序比较,97,336

  • 解包位串,719

  • 解开循环,432,763

  • 无符号

  • 条件分支,80

  • 转换

  • 十进制到字符串,495

  • 整数到字符串,扩展精度,510

  • 除法,294

  • 乘法,211,450

  • 扩展乘法,672

  • 数字,65–70

  • 使用寄存器作为变量,299

  • uSize 函数,517

  • utoStrSize 函数,522

  • V

  • VA_ARGS 符号

  • 扩展,751

  • 处理参数列表,757

  • 值参数,255

  • 临时值,311

  • vararg 参数列表,767

  • 变量参数列表,751

  • 可变长度参数,263

  • 列表,33

  • 变量

  • 32 位,56–57

  • 对齐,19–21

  • 选择内存中的位置,140

  • 自动,250

  • 字节,55

  • 声明,16–18

  • dword,57

  • 气体,16

  • 全局,300

  • 半字,56

  • 生命周期,250

  • 本地,250,300

  • 循环控制,426

  • 内存地址,19

  • 名称,16

  • 指针,178

  • qword,57

  • 只读,170

  • 范围,250,865

  • 状态,405

  • 字,57

  • 可变参数,42

  • V(溢出)条件码,14

  • 向量,128-位左移,467–468,711

  • 向量比较,687–693

  • 比较立即数,688–689

  • 跳转表,692

  • 多路分支,692

  • 浮点,689

  • 向量除法,679–680

  • 向量指令,621

  • 位测试,691

  • 向量立即加载,628

  • 向量乘法,671–678

  • 向量排列,645

  • 向量寄存器,623

  • 数据去交织,642

  • 和立即数,628

  • lane 在,625,641

  • 数据交换,625–626

  • 向量旋转,710–711

  • 向量饱和累加指令,666

  • 薄层,369

  • 垂直加法,664

  • 垂直操作,646

  • Vn 寄存器,623

  • lane 类型,624

  • 易失寄存器,31,300,346

  • 保存,907

  • 冯·诺依曼架构机器,11

  • vparmn 宏,42,167,775–777

  • W

  • .warning 指令,760

  • 汇编时的警告信息,743

  • warning 语句,743

  • 警告与错误,744

  • wastr(字对齐字符串)指令,263

  • wastr 宏,783

  • while 循环,415–417

  • 汇编中的合成,416

  • 野指针,182

  • 字数据类型,56,57

  • .word 指令,57

  • 字宏,781

  • WZR(零)寄存器,28

  • X

  • X16 和 X17 寄存器用于动态链接,369

  • X31 寄存器,146

  • Xcode,4

  • XOR(异或)操作,59–60

  • 128 位,467

  • 向量,648

  • XZR(零)寄存器,28,146

  • Z

  • 零参数宏,749

  • 零条件码,14

  • 零扩展,71–72,110,112

  • 零(Z)标志,295,716

  • 乘精度 OR 后的设置,466

  • cmp 后的设置,295

  • 清零寄存器中的位,727

  • 零终止字符串,17,187–188

  • 长度,796

  • 问题,796

  • zip 指令,641

posted @ 2025-11-25 17:04  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报