精通汇编语言编程(一)
原文:
annas-archive.org/md5/615c1868845695f8399bbdf3f670718e译者:飞龙
序言
汇编语言是任何平台上最低级的、可人类阅读的编程语言。了解汇编层面的内容将帮助开发者以更优雅、更高效的方式设计代码。
不幸的是,现代软件开发世界并不要求深入理解程序如何在低级别上执行,更不用说有许多脚本语言和不同的框架,它们简化了软件开发过程,但这些框架常常被错误地认为是低效的,因为开发者认为框架/脚本引擎应该应对代码的“笨拙”。本书的目的是展示理解基础知识的重要性,而这些基础往往被开发者的学习曲线所忽视。
汇编语言是一个强大的工具,开发者可以在项目中使用它来提高代码的效率,更不用说,汇编语言即使在当今高层语言、软件框架和脚本引擎的世界中,依然是计算机科学的基础。本书的核心思想是让软件开发者熟悉那些经常被忽略或未得到足够关注的内容,甚至更糟的是,许多教导他们的人也忽视了这些内容。或许很难相信,汇编语言本身只是冰山一角(不幸的是,隐藏在水下的部分超出了本书的范围),但即使如此,它也能显著提升你开发出更加简洁、优雅,且更高效代码的能力。
本书内容
第一章,英特尔架构,简要介绍英特尔架构,涵盖了处理器寄存器及其使用。
第二章,设置开发环境,提供了详细的汇编编程开发环境搭建说明。
第三章,英特尔指令集架构(ISA),向你介绍英特尔处理器的指令集。
第四章,内存寻址模式,概述了英特尔处理器支持的多种内存寻址模式。
第五章,并行数据处理,专门讨论英特尔架构扩展,支持多数据的并行处理。
第六章,宏指令,介绍了现代汇编器最强大的特性之一——对宏指令的支持。
第七章,数据结构,帮助我们正确组织数据,因为没有它,我们几乎无法操作数据。
第八章,将用汇编语言编写的模块与用高级语言编写的模块混合使用,描述了将我们的汇编代码与外部世界连接的各种方法。
第九章,操作系统接口,为你展示了用汇编语言编写的程序如何与 Windows 和 Linux 操作系统进行交互。
第十章,修补遗留代码,试图展示修补现有可执行文件的基本方法,这本身就是一门艺术。
第十一章,哦,差点忘了,涵盖了一些无法归入前面章节的内容,但这些内容依然有趣,甚至可能很重要。
你需要的本书内容
这本书的要求非常简单。你只需要一台运行 Windows 或 Linux 系统的计算机,以及学习新知识的欲望。
本书适合谁阅读
本书主要面向希望丰富低级操作理解的开发者,但实际上对于经验没有特殊要求,尽管我们期望读者具备一定的经验。当然,任何对汇编编程感兴趣的人都应该能在这本书中找到有用的内容。
约定
在本书中,你将看到一些文本样式,用来区分不同种类的信息。以下是这些样式的示例,并附有它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入以及 Twitter 用户名通常以以下形式显示:
“如果你决定将它移到其他地方,别忘了把 INCLUDE 文件夹和 FASMW.INI 文件(如果已经创建的话)放到同一个目录下。”
代码块的设置如下:
fld [radius] *; Load radius to ST0*
*; ST0 <== 0.2345*
fldpi *; Load PI to ST0*
*; ST1 <== ST0*
*; ST0 <== 3.1415926*
fmulp *; Multiply (ST0 * ST1) and pop*
*; ST0 = 0.7367034*
fadd st0, st0 *; * 2*
*; ST0 = 1.4734069*
fstp [result] *; Store result*
*; result <== ST0*
任何命令行输入或输出通常以以下格式呈现:
sudo yum install binutils gcc
新术语和重要单词以粗体显示。
警告或重要提示通常以这种方式呈现。
提示和技巧通常以这种方式呈现。
读者反馈
我们非常欢迎读者的反馈。告诉我们你对这本书的看法——喜欢什么或不喜欢什么。读者反馈对我们非常重要,因为它帮助我们开发出读者真正能够受益的书籍。要给我们发送一般反馈,请通过电子邮件feedback@packtpub.com,并在邮件主题中提及书名。如果你在某个领域拥有专业知识,且有兴趣写书或为书籍贡献内容,请查看我们的作者指南:www.packtpub.com/authors。
客户支持
现在你是一本 Packt 出版书籍的自豪拥有者,我们提供了许多资源,帮助你充分利用这次购买。
下载示例代码
您可以从您的账户下载本书的示例代码文件,网址为www.packtpub.com。如果您是在其他地方购买的本书,可以访问www.packtpub.com/support,注册后可以将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载和勘误。
-
在搜索框中输入书名。
-
选择您要下载代码文件的书籍。
-
从下拉菜单中选择您购买本书的地方。
-
点击代码下载。
文件下载完成后,请确保使用最新版本的工具解压或提取文件夹:
-
适用于 Windows 的 WinRAR / 7-Zip
-
适用于 Mac 的 Zipeg / iZip / UnRarX
-
适用于 Linux 的 7-Zip / PeaZip
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Assembly-Programming。我们还在github.com/PacktPublishing/上提供了其他代码包,涵盖我们丰富的书籍和视频目录,欢迎查看!
勘误
尽管我们已经尽力确保内容的准确性,但错误仍然会发生。如果您在我们的书籍中发现错误——可能是文本或代码错误——我们将非常感激您能够向我们报告。这样做不仅能帮助其他读者避免困扰,还能帮助我们改进后续版本的书籍。如果您发现任何勘误,请访问www.packtpub.com/submit-errata报告,选择您的书籍,点击“勘误提交表单”链接,输入勘误的详细信息。勘误经过验证后,您的提交将被接受,并且勘误将上传到我们的网站或添加到该书籍标题的现有勘误列表中。要查看先前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需的信息将显示在勘误部分。
盗版
互联网上的版权材料盗版问题在所有媒体中一直存在。我们在 Packt 非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取措施。请通过电子邮件联系copyright@packtpub.com并附上涉嫌盗版材料的链接。感谢您帮助我们保护作者以及确保我们能够为您提供有价值的内容。
问题
如果你在本书的任何方面遇到问题,可以通过questions@packtpub.com联系我们,我们将尽力解决问题。
第一章:英特尔架构
-你通常使用什么语言?
-C 和汇编。事实上,我喜欢用汇编编程。
-嗯……我可不敢公开承认这一点……
提到汇编语言时,人们通常会想象它是一种未知且危险的野兽,只听从编程社区中最怪异的代表,或者是一把只能用来射击自己腿部的枪。就像任何偏见一样,这种看法源于无知和对未知的原始恐惧。本书的目的不仅是帮助你克服这种偏见,还要展示如何将汇编语言变成一项强大的工具,一把锋利的手术刀,帮助你优雅而相对简单地完成某些任务,甚至是复杂的任务,避免有时由高级语言带来的不必要的复杂性。
首先,什么是汇编语言?简单而精确地说,我们可以安全地将汇编语言定义为符号化或人类可读的机器码,因为每条汇编指令都转换成一条机器指令(少数例外)。更精确地说,并没有单一的汇编语言,而是有多种汇编语言——每个平台有一种,而平台则是指可编程设备。几乎任何具有特定指令集的可编程设备都可能有其自己的汇编语言,但并非总是如此。例外的设备如 NAND 闪存芯片,虽然它们有自己的指令集,但没有从内存中获取指令并执行的手段,除非明确告知它们去执行。
为了能够有效地使用汇编语言,必须对底层平台有一个精确的理解,因为用汇编语言编程意味着直接“与设备对话”。理解越深,汇编编程的效率就越高;然而,我们不会详细探讨这一点,因为这超出了本书的范围。一本书不足以涵盖特定架构的每一个方面。由于本书将集中讨论英特尔架构,让我们尽量对英特尔的 x86/AMD64 架构有一个大致的了解,并努力加深这一理解。
本章主要讲解处理器寄存器及其功能,并简要描述内存组织(例如,分段和分页)。
-
通用寄存器:尽管在某些情况下它们具有特殊的含义,但正如这一组名称所示,这些寄存器可以用于任何目的。
-
浮点寄存器:这些寄存器用于浮点运算。
-
段寄存器:这些寄存器很少被应用程序访问(最常见的情况是在 Windows 上设置结构化异常处理程序);然而,在这里讨论它们很重要,因为它能帮助我们更好地理解 CPU 是如何看待内存的。本章讨论段寄存器的部分还涉及到一些内存组织的方面,例如分段和分页。
-
控制寄存器:这是一个非常小的寄存器组,具有重要的作用,因为它们控制着处理器的行为,并启用或禁用某些功能。
-
调试寄存器:尽管这一组寄存器主要由调试器使用,但它们为我们的代码增加了一些有趣的功能,例如在追踪程序执行时设置硬件断点的能力。
-
EFlags 寄存器:在某些平台上,这也被称为状态寄存器。这个寄存器提供了关于最新执行的算术逻辑单元(ALU)操作结果的信息,以及一些 CPU 自身的设置。
处理器寄存器
每个可编程设备,包括英特尔处理器,都有一组通用寄存器——这些是位于芯片上物理位置的存储单元,因此提供了低延迟访问。它们用于临时存储处理器操作的数据或经常访问的数据(如果通用寄存器的数量允许的话)。英特尔 CPU 的寄存器数量和位大小根据当前的操作模式而有所不同。英特尔 CPU 至少有两种模式:
-
实模式:这就是老旧的 DOS 模式。当处理器启动时,它会进入实模式,这种模式有一定的限制,例如地址总线的大小仅为 20 位,并且采用分段内存空间。
-
保护模式:该模式最早在 80286 中引入。它通过使用不同的内存分段机制,提供了对更大内存空间的访问。80386 引入的分页技术使得内存寻址虚拟化变得更加容易。
自 2003 年左右起,我们还引入了所谓的长模式——64 位寄存器/寻址(尽管并非所有 64 位都用于寻址),扁平内存模型,以及基于 RIP 的寻址(相对于指令指针寄存器的寻址)。在本书中,我们将使用 32 位保护模式(虽然有 16 位保护模式,但这超出了本书的范围)和长模式,长模式是 64 位操作模式。长模式可以视为保护模式的 64 位扩展,而保护模式则从 16 位发展到 32 位。重要的是要知道,在早期模式中可以访问的寄存器,在新模式中也可以访问,这意味着在实模式中可以访问的寄存器,在保护模式中也可以访问,保护模式中可访问的寄存器,在长模式中也可以访问(如果处理器支持长模式)。关于某些寄存器位宽的细节会在本章稍后讨论。然而,由于 16 位模式(实模式和 16 位保护模式)不再被应用开发者使用(除少数例外),在本书中,我们仅讨论保护模式和长模式。
通用寄存器
根据操作模式(保护模式或长模式),现代 Intel 处理器中有 8 到 16 个可用的通用寄存器。每个寄存器被划分为子寄存器,允许访问比寄存器宽度更小的位宽数据。
下表显示了通用寄存器(以下简称 GPR):
表 1:x86/x86_64 寄存器
所有的 R*寄存器仅在长模式下可用。寄存器 SIL、DIL、BPL 和 SPL 仅在长模式下可用。寄存器 AH、BH、CH 和 DH 不能在不适用于长模式的指令中使用。
为了方便起见,我们在不需要明确指定某个位宽寄存器时,将以其 32 位名称(如 EAX、EBX 等)来引用这些寄存器。上述表格显示了 Intel 平台上所有可用的通用寄存器。它们中的一些仅在长模式下可用(所有 64 位寄存器、R*寄存器,以及少数 8 位寄存器),并且某些组合是不允许的。然而,尽管我们可以将这些寄存器用于任何目的,但在某些情况下,它们确实有特殊的含义。
累加寄存器
EAX 寄存器也称为 累加器,用于乘法和除法操作,既作为隐含操作数,也作为目标操作数。值得一提的是,二进制乘法的结果是操作数大小的两倍,而二进制除法的结果由两个部分(商和余数)组成,每个部分的位宽与操作数相同。由于 x86 架构最初是以 16 位寄存器为基础,并且出于向后兼容性考虑,当操作数的值大于 8 位时,EDX 寄存器用于存储部分结果。例如,如果我们要将两个字节 0x50 和 0x04 相乘,预期结果是 0x140,它不能存储在一个字节中。然而,由于操作数是 8 位大小,结果存储在 AX 寄存器中,AX 是 16 位的。但如果我们要将 0x150 与 0x104 相乘,结果需要 17 位才能存储(0x150 * 0x104 = 0x15540),而如前所述,最初的 x86 寄存器只有 16 位。这就是使用额外寄存器的原因;在英特尔架构中,这个寄存器是 EDX(更准确地说,在这种情况下只使用 DX 部分)。由于口头解释有时过于概括,最好通过实际示例来展示这个规则。
| 操作数大小 | 源操作数 1 | 源操作数 2 | 目标操作数 |
|---|---|---|---|
| 8 位(字节) | AL | 8 位寄存器或 8 位内存 | AX |
| 16 位(字) | AX | 16 位寄存器或 16 位内存 | DX:AX |
| 32 位(双字) | EAX | 32 位寄存器或 32 位内存 | EDX:EAX |
| 64 位(四字) | RAX | 64 位寄存器或 64 位内存 | RDX:RAX |
除法涉及稍微不同的规则。更准确地说,这是反向乘法规则,意味着操作结果是被除数位宽的一半,这也意味着在长模式下,最大被除数可以是 128 位宽。最小的被除数值与乘法中源操作数的最小值相同——8 位。
| 操作数大小 | 被除数 | 除数 | 商 | 余数 |
|---|---|---|---|---|
| 8/16 位 | AX | 8 位寄存器或 8 位内存 | AL | AH |
| 16/32 位 | DX:AX | 16 位内存或 16 位寄存器 | AX | DX |
| 32/64 位 | EDX:EAX | 32 位寄存器或 32 位内存 | EAX | EDX |
| 64/128 位 | RDX:RAX | 64 位寄存器或 64 位内存 | RAX | RDX |
计数器
ECX 寄存器 - 也称为计数器寄存器。该寄存器在循环中用作循环迭代计数器。它首先加载一个迭代次数,然后每次执行循环指令时递减,直到 ECX 中存储的值变为零,指示处理器跳出循环。我们可以将其与 C 中的 do{...}while() 子句进行比较:
int ecx = 10;
do
{
// do your stuff
ecx--;
}while(ecx > 0);
该寄存器的另一个常见用法,实际上是其最低有效部分 CL 的用法,是位移操作,其中它包含源操作数应移位的位数。例如,考虑以下代码:
mov eax, 0x12345
mov cl, 5
shl eax, cl
这将导致寄存器 EAX 被左移 5 位(结果值为0x2468a0)。
堆栈指针
ESP 寄存器是堆栈指针。该寄存器与 SS 寄存器一起(SS 寄存器将在本章稍后解释)描述线程的堆栈区域,其中 SS 包含堆栈段的描述符,而 ESP 是指向堆栈中当前指针位置的索引。
源和目标索引
ESI 和 EDI 寄存器在字符串操作中作为源和目标索引寄存器,其中 ESI 包含源地址,EDI 显然包含目标地址。我们将在第三章中更多地讨论这些寄存器,英特尔指令集架构(ISA)。
基址指针
EBP。这个寄存器被称为基址指针,因为它最常见的用途是在函数调用期间指向堆栈帧的基址。然而,与前面讨论的寄存器不同,如果需要,你可以使用任何其他寄存器来完成此目的。
这里还值得提到另一个寄存器 EBX,它在 16 位模式的“好日子”里(当时它还是 BX 寄存器)是我们可以用作寻址基址的少数寄存器之一。与 EBP 不同,EBX(在 XLAT 指令的情况下,默认使用 DS:EBX,至今仍然如此)旨在指向数据段。
指令指针
还有一个特殊寄存器不能用于数据存储——EIP(在实模式下为 IP,长模式下为 RIP)。这是指令指针,包含当前执行的指令之后的指令地址。所有指令都会由 CPU 隐式从代码段获取;因此,执行的指令之后的完整地址应描述为 CS:IP。此外,没有常规方法可以直接修改其内容。虽然这并非不可能,但我们不能仅仅使用mov指令将值加载到 EIP 中。
所有其他寄存器从处理器的角度来看没有特殊含义,可以用于任何目的。
浮点寄存器
CPU 本身没有进行浮点运算的功能。1980 年,英特尔推出了 Intel 8087——为 8086 系列设计的浮点协处理器。8087 一直作为一个可单独安装的设备存在,直到 1989 年,英特尔推出了集成 8087 电路的 80486(i486)处理器。然而,当谈到浮点寄存器和浮点指令时,我们仍然将 8087 称为浮点单元(FPU),有时仍称其为浮点协处理器(不过后者越来越少见)。
8087 处理器有八个寄存器,每个寄存器都是 80 位,按照栈的方式排列,这意味着操作数从内存推入此栈,结果从最顶端的寄存器弹出到内存。这些寄存器命名为 ST0 到 ST7(ST--栈),其中使用最频繁的是 ST0 寄存器,可以简称为 ST。
浮点协处理器支持多种数据类型:
-
80 位扩展精度实数
-
64 位双精度实数
-
32 位单精度实数
-
18 位十进制整数
-
64 位二进制整数
-
32 位二进制整数
-
16 位二进制整数
浮点协处理器将在第三章,*Intel 指令集架构(ISA)*中详细讨论。
XMM 寄存器
128 位 XMM 寄存器是 SSE 扩展的一部分(其中SSE是流处理单指令多数据扩展的缩写)。在非 64 位模式下有八个 XMM 寄存器,在长模式下有 16 个 XMM 寄存器,允许对以下内容进行并行操作:
-
16 字节
-
八个字
-
四个双字
-
两个四字
-
四个浮点数
-
两个双精度实数
我们将在第五章,并行数据处理中更加关注这些寄存器及其背后的技术。
段寄存器和内存组织
内存组织是 CPU 设计中最重要的方面之一。首先要注意的是,当我们说“内存组织”时,我们并不是指它在内存芯片/板上的物理布局。对我们来说,更重要的是 CPU 如何看待内存以及它如何与之通信(当然,这是在更高层次上,因为我们不会深入讨论架构的硬件方面)。
然而,由于本书专注于应用程序编程,而非操作系统开发,在本节中我们将进一步考虑内存组织和访问中最相关的方面。
实模式
段寄存器是一个非常有趣的主题,因为它们告诉处理器哪些内存区域可以访问,以及如何访问。在实模式下,段寄存器用于包含一个 16 位段地址。普通地址和段地址的区别在于后者在存储到段寄存器时向右移动 4 位。例如,如果某个段寄存器加载了0x1234值,实际上指向的地址是0x12340;因此,在实模式中,指针实际上是段寄存器指向的段的偏移量。例如,我们来看看 DI 寄存器(因为我们现在讨论的是 16 位实模式),它会自动与 DS(数据段)寄存器一起使用,并且当 DS 寄存器加载了0x1234值时,将其加载为0x4321,那么 20 位地址将会是0x12340 + 0x4321 = 0x16661。因此,在实模式下最多可以寻址 1MB 内存。
总共有六个段寄存器:
-
CS:该寄存器包含当前使用的代码段的基地址。
-
DS:该寄存器包含当前使用的数据段的基地址。
-
SS:该寄存器包含当前使用的堆栈段的基地址。
-
ES:这是供程序员使用的附加数据段。
-
FS和GS:这两个寄存器是随着 Intel 80386 处理器引入的。这两个段寄存器没有特定的硬件定义功能,供程序员使用。需要知道的是,它们在 Windows 和 Linux 中有特定的任务,但这些任务仅与操作系统相关,并与硬件规范无关。
CS 寄存器与 IP 寄存器(指令指针,也叫程序计数器)一起使用,其中 IP(在保护模式下为 EIP,在长模式下为 RIP)指向当前正在执行的指令的偏移量,在代码段中跟随该指令。
使用 SI 和 DI 寄存器时,分别隐含使用 DS 和 ES 段寄存器,除非指令中隐式指定了其他段寄存器。例如,lodsb指令虽然没有操作数,但会从由 DS:SI 指定的地址加载一个字节到 AL 寄存器中,而stosb指令(同样没有可见操作数)会将 AL 寄存器中的一个字节存储到由 ES:DI 指定的地址中。使用 SI/DI 寄存器与其他段时,需要显式提及相关段寄存器。考虑以下代码示例:
mov ax, [si]
mov [es:di], ax
上述代码从 DS:SI 指向的位置加载一个双字,并将其存储到由 ES:DI 指向的另一个位置。
段寄存器和段的一个有趣之处在于,它们可以平稳重叠。例如,如果你想将一部分代码复制到代码段中的另一个位置或临时缓冲区(例如,用于解密器),此时,CS 和 DS 寄存器可以指向相同位置,或者 DS 寄存器可以指向代码段的某个地方。
保护模式 - 分段
在实模式下,一切都很简单明了,但到了保护模式,事情变得复杂了。不幸的是,内存分段仍然存在,但段寄存器不再包含地址。相反,它们加载了所谓的选择子,这些选择子是描述符表中的索引,并乘以 8(向左移位 3 位)。最低的两位表示请求的权限级别(0 表示内核空间,3 表示用户空间)。第三位(在索引 2 处)是TI位(表指示符),表示所引用的描述符是位于全局描述符表(0)还是局部描述符表(1)。内存描述符是一个小型 8 字节结构,描述了物理内存的范围、访问权限和一些附加属性:
表 2:内存描述符结构
描述符至少存储在两个表中:
-
GDT:全局描述符表(由操作系统使用)
-
LDT:局部描述符表(每个任务描述符表)
正如我们可以得出的结论,保护模式下的内存组织实际上与实模式并没有太大的不同。
还有其他类型的描述符——中断描述符(存储在中断描述符表(IDT)中)和系统描述符;然而,由于这些只在内核空间中使用,我们不会讨论它们,因为它们超出了本书的范围。
保护模式 - 分页
分页是一种在 80386 中引入的更方便的内存管理方案,并且此后有所增强。分页的核心思想是内存虚拟化——这是使不同进程能够拥有相同内存布局的机制。实际上,我们在指针中使用的地址(如果我们用 C、C++或任何其他编译成本地代码的高级语言编程)是虚拟地址,并不对应于物理地址。虚拟地址到物理地址的转换由硬件实现,由 CPU 执行(不过,也可能有一些操作系统干预)。
默认情况下,32 位 CPU 使用两级转换方案将提供的虚拟地址转换为物理地址。
以下表格解释了如何使用虚拟地址来查找物理地址:
| 地址位 | 含义 |
|---|---|
| 0 - 11 | 在 4KB 页面中的偏移量 |
| 12 - 21 | 1024 页的页表中的页项索引 |
| 22 - 31 | 页目录中 1024 条目页表项的索引 |
表 3:虚拟地址到物理地址的转换
大多数基于 Intel 架构的现代处理器都支持页面大小扩展(PSE),这使得使用所谓的 4 MB 大页面成为可能。在这种情况下,虚拟地址到物理地址的转换有所不同,因为不再有页面表。以下表格展示了 32 位虚拟地址中各位的含义:
| 地址位 | 含义 |
|---|---|
| 0 - 21 | 4 MB 页面中的偏移量 |
| 22 - 31 | 1024 项页面目录中相应条目的索引 |
表 4:启用 PSE 时虚拟地址到物理地址的转换
此外,物理地址扩展(PAE)被引入,显著改变了地址映射方案,允许访问更大的内存范围。在保护模式下,PAE 增加了一个四项条目的页面目录指针表,虚拟地址到物理地址的转换如下表所示:
| 地址位 | 含义 |
|---|---|
| 0 - 11 | 4 KB 页面中的偏移量 |
| 12 - 20 | 512 页面表中的页面条目的索引 |
| 21 - 29 | 512 项页面目录中的页面表项索引 |
| 30 - 31 | 四项页面目录指针表中页面目录条目的索引 |
表 5:启用 PAE(未启用 PSE)时的虚拟地址到物理地址转换
启用 PSE 并同时启用 PAE 会强制页面目录中的每个条目直接指向一个 2 MB 的大页面,而不是指向页面表中的条目。
长模式 - 分页
长模式下唯一允许的地址虚拟化方式是启用 PAE 的分页;但是,它增加了一个新的表——页面映射级别 4 表作为根条目。因此,虚拟地址到物理地址的转换按以下表格所示,使用虚拟地址中的各个位:
| 地址位 | 含义 |
|---|---|
| 0 - 11 | 4 KB 页面中的偏移量 |
| 12 - 20 | 512 页面表中的页面条目的索引 |
| 21 - 29 | 页面目录中页表项的索引 |
| 30 - 38 | 页面目录指针表中页面目录条目的索引 |
| 39 - 47 | 页面目录指针表在页面映射级别 4 表中的索引 |
表 6:长模式下虚拟地址到物理地址的转换
然而,需要指出的是,尽管它是 64 位架构,MMU 仅使用虚拟地址的前 48 位(也称为线性地址)。
地址解析的整个过程由 CPU 内部的内存管理单元(MMU)执行,程序员仅需负责实际构建这些表并启用 PAE/PSE。然而,这个话题远远超出了本书的范围,涉及的内容较广。
控制寄存器
基于 Intel 架构的处理器有一组控制寄存器,用于在运行时配置处理器(例如切换执行模式)。这些寄存器在 x86 上为 32 位宽,在 AMD64(长模式)上为 64 位宽。
有六个控制寄存器和一个扩展功能启用寄存器(EFER):
-
CR0:此寄存器包含修改处理器基本操作的各种控制标志。
-
CR1:此寄存器保留供未来使用。
-
CR2:当发生页面错误时,此寄存器包含页面错误的线性地址。
-
CR3:此寄存器在启用虚拟地址(分页)时使用,并包含页面目录、页面目录指针表或页面映射级别 4 表的物理地址,具体取决于当前的操作模式。
-
CR4:此寄存器在保护模式下用于控制处理器的不同选项。
-
CR8:此寄存器是新的,仅在长模式下可用。它用于外部中断的优先级排序。
-
EFER:此寄存器是多个特定于型号的寄存器之一。它用于启用/禁用 SYSCALL/SYSRET 指令、进入/退出长模式以及其他一些功能。其他特定于型号的寄存器对我们无关紧要。
然而,这些寄存器在ring3(用户态)中不可访问。
调试寄存器
除了控制寄存器外,处理器还具有一组所谓的调试寄存器,这些寄存器主要由调试器用于设置所谓的硬件断点。事实上,这些寄存器在控制其他线程甚至进程时是非常强大的工具。
调试地址寄存器 DR0 - DR3
调试寄存器 0 到 3(DR0、DR1、DR2 和 DR3)用于存储所谓的硬件断点的虚拟(线性)地址。
调试控制寄存器(DR7)
DR7 定义了调试地址寄存器中设置的断点如何被处理器解释,以及是否需要被解释。
该寄存器的位布局如下表所示:
表 3:DR7 位布局
L位,当设置为 1 时,在相应的调试地址寄存器中指定的地址处启用断点--在任务内本地启用。这些位在每次任务切换时由处理器重置。G位,相反,启用全局断点--适用于所有任务,意味着这些位不会被处理器重置。
R/W*位指定断点条件,如下所示:
-
00:在指令执行时断点 -
01:仅在指定地址被写入时触发断点 -
10:未定义 -
11:在读取或写入访问时,或在指定地址处执行指令时触发断点
LEN*位指定断点的大小(以字节为单位),因此可以覆盖多个指令或多个字节的数据:
-
00:断点为 1 字节长 -
01:断点为 2 字节长 -
10: 断点为 8 字节长(仅长模式下) -
11: 断点为 4 字节长
调试状态寄存器(DR6)
当启用断点触发时,DR6 中低四位的相应位会在进入调试处理程序之前被置为 1,从而为处理程序提供有关触发断点的信息(位 0 对应 DR0 中的断点,位 1 对应 DR1 中的断点,依此类推)。
EFlags 寄存器
如果处理器没有办法报告其状态和/或最后一次操作的状态,任何语言的程序都无法在给定的平台上编写。更重要的是,处理器本身有时也需要这些信息。试想一下,如果处理器无法有条件地控制程序的执行流程——这听起来像一场噩梦,不是吗?
获取有关最后操作或 Intel 架构处理器某些配置的信息,程序最常用的方式是通过EFlags寄存器(E代表扩展)。该寄存在实模式下称为 Flags,在保护模式下称为 EFlags,在长模式下称为RFlags。
让我们来看看这个寄存器中各个位(也称为标志)的含义及其使用。
位 #0 - 进位标志
进位标志(CF)主要用于检测算术运算中的进位/借位,并在最后一次此类运算的结果位宽(例如加法和减法)超出 ALU 的位宽时置位。例如,两个 8 位值 255 和 1 相加会得到 256,这需要至少 9 位来存储。在这种情况下,第八位(第九位)被置入 CF,从而让我们和处理器知道最后的操作有进位。
位 #2 - 奇偶标志
奇偶标志(PF)在最低有效字节中 1 的个数为偶数时置为 1;否则,置为零。
位 #4 - 调整标志
调整标志(AF)在最低有效的四个位(低半字节)发生进位或借位时被置位,主要用于二进制编码十进制(BCD)算术运算。
位 #6 - 零标志
零标志(ZF)在算术或按位操作的结果为 0 时置位。这包括没有存储结果的操作(例如比较和位测试)。
位 #7 - 符号标志
符号标志(SF)在上一次数学运算结果为负数时被置位;换句话说,当结果的最高有效位被置位时,符号标志会被置位。
位 #8 - 陷阱标志
当置位时,陷阱标志(TF)会在每条指令执行后引发单步中断。
位 #9 - 中断使能标志
中断使能标志(IF)定义处理器是否对传入的中断做出反应。该标志仅在实模式或其他模式下的 Ring 0 保护级别下可访问。
位 #10 - 方向标志
方向标志(DF)控制字符串操作的方向。如果标志被复位(为 0),操作会从低地址到高地址执行;如果标志被置位(为 1),操作则从高地址到低地址执行。
位 #11 - 溢出标志
溢出标志(OF)有时被认为是进位标志的二进制补码形式,但实际上并非如此。OF 在操作的结果太小或太大,无法适配目标操作数时被置位。例如,考虑将两个 8 位正值 0x74 和 0x7f 相加。这次加法的结果是 0xf3,仍然是 8 位的,对于无符号数来说是可以接受的,但由于我们加的是两个有符号数,必须考虑符号位,而存储 9 位有符号结果的地方已经没有足够的位数。如果我们尝试加上两个负的 8 位数 0x82 和 0x81,也会发生同样的情况。两个负数相加的意义在于从负数中减去正数,结果应当是一个更小的数。因此,0x82 + 0x81 将得到 0x103,其中第九位(1)是符号位,但它无法存储在一个 8 位操作数中。更大的操作数(如 16、32 和 64 位)也是如此。
剩余的位
在用户模式下,EFlags 寄存器的剩余 20 位对我们来说并不重要,除非可能是 ID 位(位 #21)。ID 标志指示我们是否可以使用 CPUID 指令。
在长模式下,RFlags 寄存器的第 32 位到第 63 位将全部为 0。
总结
在本章中,我们简要地介绍了 x86 架构处理器的内部结构基础知识,这对进一步理解后续章节的主题至关重要。作为奥卡姆剃刀原理的忠实粉丝,我本人并不打算重复英特尔的程序员手册;然而,本章涉及的一些话题超出了成功开始汇编语言编程所必需的内容。
然而,我相信你会同意——我们已经了解了足够的干货,现在是时候开始动手做一些事情了。让我们从在第二章中设置开发环境开始,设置开发环境。
第二章:设置开发环境
我们正慢慢接近能够开始实际处理汇编语言的时刻——编写代码、检查程序、解决问题。我们只差一步,那就是为汇编编程设置开发环境。
尽管本书使用的汇编器是平面汇编器(FASM),但重要的是至少要了解另外两种选择,因此在本章中,您将学习如何配置三种类型的开发环境:
-
为 Windows 平台应用程序设置开发环境(使用 Visual Studio 2017 Community):这将使汇编项目能够与现有解决方案直接集成
-
安装 GNU 编译器集合(GCC):虽然 GCC 可以在 Windows 和*nix 平台上使用,但我们将重点强调在 Linux 上使用 GCC
-
平面汇编器:这个汇编器似乎是最简单且最舒适的,用于 Windows 或 Linux 上的汇编编程
我们将在每个章节结束时提供一个用汇编语言编写的简单测试程序,该程序是专门为该章节描述的汇编器编写的。
微软宏汇编器
正如这个汇编器的名字所示,它支持宏并且内置了一些很好的宏。然而,今天如果没有这个特性,很难找到一个多少有点价值的汇编器。
我第一次使用的汇编器是宏汇编器(MASM)(我不记得是哪一版本),它安装在一台配备 4MB RAM 和 100MB 硬盘的索尼笔记本电脑上(啊,那个美好的旧时光),而 MS-DOS 的 edit.exe 是唯一的集成开发环境。无需多言,编译和链接都需要在命令行中手动完成(就像 DOS 有其他界面一样)。
在我看来,这是学习汇编语言或任何其他编程语言的最佳方式——仅仅是一个功能尽可能少的简单编辑器(不过,语法高亮是一个很大的优势,因为它有助于避免拼写错误)和一组命令行工具。现代的集成开发环境(IDEs)是非常复杂的,但也非常强大的工具,我并不是想低估它们;然而,一旦你了解了这些复杂背后的内容,使用它们会更好。
然而,本书的目的是学习 CPU 所使用的语言,而不是特定的汇编方言或特定汇编器命令行选项。更不用说,目前可用的 Microsoft Visual Studio 2017 Community(获取 MASM 最简单的方法是安装 Visual Studio 2017 Community——免费且方便)附带了多个汇编器二进制文件:
-
一个生成 32 位代码的 32 位二进制文件
-
一个生成 64 位代码的 32 位二进制文件
-
一个生成 32 位代码的 64 位二进制文件
-
一个生成 64 位代码的 64 位二进制文件
我们的目标是了解 CPU 是如何思考的,而不是如何让它理解我们的思维,并且我们如何找到系统中已安装的库和可执行文件的位置。因此,如果 MASM 是你的选择,使用 Visual Studio 2017 Community 会节省你大量时间。
安装 Microsoft Visual Studio 2017 Community
如果你已经安装了 Microsoft Visual Studio 2017 Community 或任何其他版本的 Microsoft Visual Studio,你可以安全跳过这一步。
这是本书中描述的最简单的操作之一。访问 www.visualstudio.com/downloads/ 下载并运行 Visual Studio 2017 Community 的安装程序。
安装程序有许多选项,你可能根据开发需求选择一些选项;然而,我们用于汇编开发的选项是“使用 C++ 的桌面开发”。
如果你坚持使用命令行构建你的汇编程序,可以在以下位置找到 MASM 可执行文件:
VS_2017_install_dir\VC\bin\amd64_x86\ml.exe
VS_2017_install_dir\VC\bin\amd64\ml64.exe
VS_2017_install_dir\VC\bin\ml.exe
VS_2017_install_dir\VC\bin\x86_amd64\ml64.exe
设置程序集项目
不幸的是,Visual Studio 默认没有汇编语言项目的模板,因此我们需要自己创建一个:
- 启动 Visual Studio 并创建一个空的解决方案,如下图所示:
创建空白的 VS2017 解决方案
查看开始页面窗口的右下角,你会看到创建空白解决方案的选项。如果没有该选项,点击“更多项目模板...”并从中选择“空白解决方案”。
- 一旦解决方案创建完成,我们可以添加一个新项目。右键点击解决方案的名称,然后选择添加 | 新建项目:
向解决方案添加新项目
由于 Visual Studio 没有内置的汇编项目模板,我们将向解决方案中添加一个空的 C++ 项目:
创建一个空项目
-
选择一个项目名称并点击确定。在我们可以添加源文件之前,还有两件事需要做。更准确地说,我们可以先添加源文件,然后再处理这两件事,因为顺序其实不重要。只需记住,在处理完这些之前,我们是无法构建(或者正确构建)项目的。
-
第一个需要处理的事情是为项目设置子系统;否则,链接器将无法知道生成哪种可执行文件。
右键点击解决方案资源管理器标签中的项目名称,选择属性。在项目属性窗口中,依次选择配置属性 | 链接器 | 系统,并在子系统下选择 Windows (/SUBSYSTEM:WINDOWS):
设置目标子系统
- 下一步是告诉 Visual Studio 这是一个汇编语言项目:
打开“构建自定义”窗口
- 右键点击项目名称,在上下文菜单中选择构建依赖项,点击构建自定义...,然后在构建自定义窗口中选择
masm(.targets, .props):
设置适当的目标
- 现在我们准备好添加第一个汇编源文件了:
添加新的汇编源文件
不幸的是,Visual Studio 似乎并没有为汇编项目做准备,因此没有内置的汇编文件模板。所以,我们右键点击解决方案资源管理器中的源文件,选择“添加”下的“新建项”,由于没有汇编源文件的模板,我们选择 C++ 文件(.cpp),但将文件名设置为 .asm 扩展名。点击添加,瞧!我们的第一个汇编源文件就出现在了 IDE 中。
- 为了好玩,让我们添加一些代码:
.686
.model flat, stdcall
*; this is a comment*
*; Imported functions*
ExitProcess proto uExitCode:DWORD
MessageBoxA proto hWnd:DWORD, lpText:DWORD, lpCaption:DWORD,
uType:DWORD
*; Here we tell assembler what to put into data section*
.data
msg db 'Hello from Assembly!', 0
ti db 'Hello message', 0
*; and here what to put into code section*
.code
*; This is our entry point*
main PROC
push 0 *; Prepare the value to return to the*
*; operating system*
push offset msg *; Pass pointer to MessageBox's text to*
*; the show_message() function*
push offset ti *; Pass pointer to MessageBox's title to*
*; the show_message() function*
call show_message *; Call it*
call ExitProcess *; and return to the operating system*
main ENDP
*; This function's prototype would be:*
*; void show_message(char* title, char* message);*
show_message PROC
push ebp
mov ebp, esp
push eax
push 0 *; uType*
mov eax, [dword ptr ebp + 8]
push eax *; lpCaption*
mov eax, [dword ptr ebp + 12]
push eax *; lpText*
push 0 *; hWnd*
call MessageBoxA *; call MessageBox()*
pop eax
mov esp, ebp
pop ebp
ret 4 * 2 *; Return and clean the stack*
show_message ENDP
END main
如果代码目前还没有“跟你说话”,不要担心;我们将在第三章开始熟悉指令和程序结构,英特尔指令集架构(ISA)。
现在,让我们构建项目并运行它。代码并不做太多事情,它只是显示一个消息框并终止程序:
示例输出
到目前为止,我们已经在 Windows 上设置好了 Assembly 开发环境。
GNU 汇编器(GAS)
GNU 汇编器(GAS),简称 AS,是在 *nix(Unix 和 Linux)平台上使用最广泛的汇编器。虽然它是跨平台的(通过正确版本的 GAS,我们可以为各种平台编译汇编代码,包括 Windows),灵活且功能强大,但它默认使用 AT&T 语法,对于习惯 Intel 语法的人来说,至少可以说有些奇怪。GAS 是自由软件,遵循 GNU 通用公共许可证 v3 发布。
安装 GAS
GAS 是作为 binutils 包的一部分分发的,但由于它是 GCC(GNU 编译器集合)的默认后端,最好还是安装 GCC。事实上,安装 GCC 而不是仅安装 binutils 会稍微简化从汇编代码生成可执行文件的过程,因为 GCC 在链接过程中会自动处理一些任务。尽管 GAS 起源于 *nix 系统,但它也可在 Windows 上使用,可以从 sourceforge.net/projects/mingw-w64/ 下载(只需记得将安装文件夹中的 bin 子文件夹添加到 PATH 环境变量中)。在 Windows 上的安装过程相当简单,只需按照 GUI 安装向导的步骤进行操作。
对于我们这些使用 Windows 的人,另一个选择是“Windows 上的 Bash”;然而,这只在安装了周年更新或创意者更新的 64 位 Windows 10 上可用。安装 GAS 的步骤与运行 Ubuntu 或 Debian Linux 时的步骤相同。
由于本书面向开发人员,假设你已经在系统上安装了它是可以的,但如果你使用的是*nix 系统,我们还是不做假设,直接安装 GAS。
第 1 步 - 安装 GAS
打开你最喜欢的终端模拟器并执行以下命令:
sudo apt-get install binutils gcc
如果你使用的是基于 Debian 的发行版,或者是基于 RH 的发行版,可以使用以下命令:
sudo yum install binutils gcc
另外,你也可以使用以下方法:
su -c "yum install binutils gcc"
第 2 步 - 测试一下
准备好之后,让我们在 Linux 上构建我们的第一个汇编程序。创建一个名为test.S的汇编源文件。
在*nix 平台上,汇编源文件的扩展名是.S或.s,而不是.asm。
填入以下代码:
*/**
*This is a multiline comment.*
**/*
*// This is a single line comment.*
*# Another single line comment.*
*# The following line is not a necessity.*
.file "test.S"
*# Tell GAS that we are using an external function.*
.extern printf
*# Make some data - store message in data section 0*
.data
msg:
.ascii "Hello from Assembly language!xaxdx0"
*# Begin the actual code*
.text
*# Make main() publicly visible*
.globl main
*/**
*This is our main() function.*
*It is important to mention,*
*that we can't begin the program with*
*'main()' when using GAS alone. We have then*
*to begin the program with 'start' or '_start'*
*function.*
**/*
main:
pushl %ebp
movl %esp, %ebp
pushl $msg *# Pass parameter (pointer*
*# to message) to output_message function.*
call output_message *# Print the message*
movl $0, %eax
leave
ret
*# This function simply prints out a message to the Terminal*
output_message:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
call _printf *# Here we call printf*
addl $4, %esp
movl $0, %eax
leave
ret $4
如果你在 Windows 上,请在printf和main前加上下划线(_)。
如果你在 Linux 上,可以使用以下命令来构建代码:
gcc -o test test.S
为了确保这段代码能够在 64 位系统上正确编译,因为它是为 32 位汇编器编写的,你应该安装 32 位工具链和库,并添加-m32选项,这告诉 GCC 为 32 位平台生成代码,命令如下:
gcc -m32 -o test test.S
请参考你所使用的 Linux 发行版的文档,了解如何安装 32 位库。
如果你在 Windows 上,请相应地更改输出的可执行文件名称:
gcc -o test.exe test.S
在终端运行可执行文件。你应该看到一条消息,后面跟着一个新行:
Hello from Assembly language!
如你所见,这段汇编源代码的语法不同于 MASM 所支持的语法。MASM 支持所谓的 Intel 语法,而 GAS 最初只支持 AT&T 语法。然而,后来添加了对 Intel 语法的支持,从而大大简化了新手的学习过程。
Flat Assembler
现在我们已经看到了 MASM 和 GAS 带来的复杂性,无论是语法还是配置的复杂性,我们来看看 Flat Assembler,它是一个免费的、便携的、自编译的汇编器,适用于 Windows 和 Linux,使用 Intel 语法(与 MASM 非常相似,但复杂性更低,更容易理解)。这正是我们需要的工具,可以更轻松、更快速地理解 Intel 汇编语言及其使用。
除了支持各种可执行文件格式(最初是 DOS COM 文件,通过 Windows PE(包括 32 位和 64 位),直到 ELF(包括 32 位和 64 位)),FASM 还有一个非常强大的宏引擎,我们肯定会加以利用。更不用说 FASM 可以轻松集成到现有的开发环境中,适用于更复杂的项目。
安装 Flat Assembler
无论你是在 Windows 还是 Linux 上,都可以通过相同的简单方式获取 Flat Assembler:
- 首先,访问
flatassembler.net/download.php并选择适合你操作系统的包:
Flat Assembler 下载页面
- 解压包。Windows 和 Linux 版本的包都包含了 FASM 源码、文档和示例。如我们在以下截图中所见,Windows 版本包含了两个可执行文件:
fasm.exe和fasmw.exe。两者之间的唯一区别是,fasmw.exe是 Flat Assembler 的图形界面实现,而fasm.exe仅支持命令行:
Flat Assembler 包的内容
这两个可执行文件可以从你解压包的目录运行,因为它们没有外部依赖。如果你决定将其移动到其他地方,别忘了将 INCLUDE 文件夹和 FASMW.INI 文件(如果已经创建的话)一起放入同一目录。如果你复制了 FASMW.INI 文件,你需要手动编辑 [Environment] 部分下的 Include 路径。或者,你可以跳过复制 FASMW.INI,因为它会在你第一次启动 FASMW.EXE 时自动创建。
Linux 版本缺少图形界面部分,但它仍然包含 fasm 源代码、文档和示例:
Flat Assembler Linux 版本包的内容
与 Windows 版本一样,Linux 版本的 fasm 可执行文件没有外部依赖,可以直接从解压包所在的文件夹运行,但为了方便,最好将其复制到一个更合适的位置,例如 /usr/local/bin。
第一个 FASM 程序
现在我们已经安装了 Flat Assembler,除非我们为 Windows 或 Linux 构建一个小的测试可执行文件,否则无法继续。很有趣的是,这两个示例可以用相同的汇编器编译,这意味着 Linux 示例也可以在 Windows 上编译,反之亦然。但让我们直接看一下这个示例。
Windows
如果你使用的是 Windows,启动 fasmw.exe 并输入以下代码:
include 'win32a.inc'
format PE GUI
entry _start
section '.text' code readable executable
_start:
push 0
push 0
push title
push message
push 0
call [MessageBox]
call [ExitProcess]
section '.data' data readable writeable
message db 'Hello from FASM!', 0x00
title db 'Hello!', 0x00
section '.idata' import data readable writeable
library kernel, 'kernel32.dll',
user, 'user32.dll'
import kernel,\
ExitProcess, 'ExitProcess'
import user,\
MessageBox, 'MessageBoxA'
再次提醒,如果你对代码中的内容几乎不理解,也无需担心;接下来的章节会让它变得更清晰。
为了运行前面的代码,进入“运行”菜单并选择“运行”。
在 FASMW 中编译源代码
欣赏结果几秒钟。
示例输出
Linux
如果你使用的是 Linux,源代码会更简短。打开你最喜欢的源代码编辑器,无论是 nano、emacs、vi 或其他任何编辑器,并输入以下代码:
format ELF executable 3
entry _start
segment readable executable
_start:
mov eax, 4
mov ebx, 1
mov ecx, message
mov edx, len
int 0x80
xor ebx, ebx
mov eax, ebx
inc eax
int 0x80
segment readable writeable
message db 'Hello from FASM on Linux!', 0x0a
len = $ - message
这段代码比在 Windows 上的要紧凑得多,因为我们没有使用任何高级 API 函数;我们宁愿直接使用 Linux 系统调用(在 Windows 上这样做可能会变成一场噩梦)。将文件保存为 fasm1lin.asm(这不是 GAS 或 GCC,因此我们可以给汇编源文件使用常规扩展名),然后打开终端模拟器。输入以下命令(假设 fasm 可执行文件在 PATH 环境变量中提到的地方),以便从这段代码中构建可执行文件:
fasm fasm1lin.asm fasm1lin
然后,尝试使用以下命令运行该文件:
./fasm1lin
你应该看到类似这样的内容:
使用平面汇编器构建和运行 Linux 可执行文件
就这么简单。
总结
到目前为止,我们已经回顾了三种不同的汇编器:微软宏汇编器 (MASM),这是 Visual Studio 的一个重要组成部分;GNU 汇编器 (GAS),这是 GNU 编译器集合(GCC)的默认后端;平面汇编器 (FASM),这是一个独立的、便携的、灵活的强大汇编器。
尽管我们将使用 FASM,但在某些情况下(而且这些情况确实会发生),我们仍然会参考其他两种汇编器。
有了安装并正常工作的汇编器,我们可以继续进行 第三章,Intel 指令集架构 (ISA),并开始直接使用汇编语言了。前方的路还很长,我们甚至还没有迈出第一步。在 第三章,Intel 指令集架构 (ISA) 中,我们将深入了解 Intel 处理器的指令集架构,并学习如何为 Windows 和 Linux 编写简单程序,支持 32 位和 64 位。
第三章:英特尔指令集架构(ISA)
几乎可以说,任何数字设备都有一套特定的指令。甚至一个晶体管,作为现代数字电子学的基石,也有两个指令,开和关,每个指令用 1 或 0 表示(哪一个表示开和关取决于晶体管是n-p-n还是p-n-p)。处理器由数百万个晶体管构成,同样也由 1 和 0 的序列控制(这些序列被分组成 8 位字节,进而组成指令)。幸运的是,我们不必担心指令编码(毕竟现在是 21 世纪),因为汇编器会为我们做这些事。
每条 CPU 指令(这对于任何 CPU 都适用,不仅仅是基于英特尔的)都有一个助记符(以下简称助记符),你需要学习这个助记符以及一些关于操作数大小(和内存寻址,具体内容将在第四章,内存寻址模式中深入探讨)的简单规则,这正是我们在本章要做的事情。
我们将从创建一个简单的汇编模板开始,这个模板将贯穿全书,作为我们代码的起始点。接着,我们将进入实际的 CPU 指令集,熟悉以下类型的指令:
-
数据传输指令
-
算术指令
-
浮点指令
-
执行流控制指令
-
扩展
汇编源模板
我们将从两个 32 位模板开始,一个用于 Windows,一个用于 Linux。64 位模板将很快添加进来,我们会看到它们与 32 位模板没有太大区别。这些模板包含一些宏指令和指令,这些将在书中稍后解释。至于现在,这些模板仅提供了让你能够编写简单(或不那么简单)代码片段、编译它们并在调试器中测试它们的能力。
Windows 汇编模板(32 位)
一个 Windows 可执行文件由多个部分组成(PE 可执行文件/对象文件的结构将在第九章,操作系统接口中更详细地讨论);通常包含一个代码部分,一个数据部分和一个导入数据部分(其中包含有关从动态链接库导入的外部过程的信息)。动态链接库(DLL)也有一个导出部分,包含该 DLL 中公开的过程/对象信息。在我们的模板中,我们只是定义这些部分,并让汇编器完成剩余的工作(编写头文件等)。
现在,让我们来看看模板本身。有关 PE 特定细节的进一步说明请参见注释:
*; File: srctemplate_win.asm*
*; First of all, we tell the compiler which type of executable we want it*
*; to be. In our case it is a 32-bit PE executable.*
format PE GUI
*; Tell the compiler where we want our program to start - define the entry*
*; point. We want it to be at the place labeled with '_start'.*
entry _start
*; The following line includes a set of macros, shipped with FASM, which*
*; are essential for the Windows program. We can, of course, implement all*
*; we need ourselves, and we will do that in chapter 9.*
include 'win32a.inc'
*; PE file consists of at least one section.*
*; In this template we only need 3:*
*; 1\. '.text' - section that contains executable code*
*; 2\. '.data' - section that contains data*
*; 3\. '.idata' - section that contains import information*
*;*
*; '.text' section: contains code, is readable, is executable*
section '.text' code readable executable
_start:
*;*
*; Put your code here*
*;*
*; We have to terminate the process properly*
*; Put return code on stack*
push 0
*; Call ExitProcess Windows API procedure*
call [exitProcess]
*; '.data' section: contains data, is readable, may be writeable*
section '.data' data readable writeable
*;*
*; Put your data here*
*;*
*; '.idata' section: contains import information, is readable, is* *writeable*
section '.idata' import data readable writeable
*; 'library' macro from 'win32a.inc' creates proper entry for importing*
*; procedures from a dynamic link library. For now it is only 'kernel32.dll',*
*; library kernel, 'kernel32.dll'*
*; 'import' macro creates the actual entries for procedures we want to import*
*; from a dynamic link library*
import kernel,
exitProcess, 'ExitProcess'
Linux 汇编模板(32 位)
在 Linux 上,虽然磁盘上的文件被划分为多个部分,但内存中的可执行文件则划分为代码段和数据段。以下是我们的 Linux 32 位 ELF 可执行文件模板:
*; File: src/template_lin.asm*
*; Just as in the Windows template - we tell the assembler which type*
*; of output we expect.*
*; In this case it is 32-bit executable ELF*
format ELF executable
*; Tell the assembler where the entry point is*
entry _start
*; On *nix based systems, when in memory, the space is arranged into*
*; segments, rather than in sections, therefore, we define*
*; two segments:*
*; Code segment (executable segment)*
segment readable executable
*; Here is our entry point*
_start:
*; Set return value to 0*
xor ebx, ebx
mov eax, ebx
*; Set eax to 1 - 32-bit Linux SYS_exit system call number*
inc eax
*; Call kernel*
int 0x80
*; Data segment*
segment readable writeable
db 0
*; As you see, there is no import/export segment here. The structure*
*; of an ELF executable/object file will be covered in more detail*
*; in chapters 8 and 9*
如前面代码所提到的,这两个模板将作为我们在本书中编写的任何代码的起点。
数据类型及其定义
在我们开始编写汇编指令之前,我们必须知道如何定义数据,或者更准确地说,如何告诉汇编器我们正在使用的数据类型。
Flat Assembler 支持六种内置数据类型,并允许我们定义或声明变量。这里定义和声明的区别在于,当我们定义一个变量时,我们同时为它赋予一个特定的值,而声明时,我们只是为某种数据类型保留空间:
变量定义格式:[label] definition_directive value(s)
label:这是可选的,但引用未命名的变量会更困难。
变量声明格式:[label] declaration_directive count
-
label:这是可选的,但引用未命名的变量会更困难。 -
count:这告诉汇编器它需要为declaration_directive中指定的类型预留多少个数据条目
下表展示了按大小排序的内置数据类型的定义和声明指令:
| 数据类型的字节大小 | 定义指令 | 声明(预留空间)指令 |
|---|---|---|
| 1 | db 文件(包括二进制文件) | rb |
| 2 | dw du(定义 unicode 字符) | rw |
| 4 | dd | rd |
| 6 | dp df | rp rf |
| 8 | dq | rq |
| 10 | dt | rt |
上表列出了按字节大小排序的可接受数据类型,最左侧列出的是这些类型的字节大小。中间的列包含我们在汇编代码中用来定义某种类型数据的指令。例如,如果我们想定义一个名为my_var的字节变量,那么我们会写如下代码:
my_var db 0x5a
在这里,0x5a是我们为该变量赋予的值。在不需要初始化变量为特定值的情况下,我们可以写成如下方式:
my_var db ?
在这里,问号(?)意味着汇编器可以将此变量占用的内存区域初始化为任何值(通常为0)。
有两个指令需要更多注意:
-
file:该指令告诉汇编器在编译过程中包含一个二进制文件。 -
du:此指令的使用方法与db类似,用于定义字符或其字符串,但它生成的是类似 unicode 的字符/字符串,而不是 ASCII。其效果是将 8 位值扩展为 16 位值。这是一个便利指令,当需要进行适当的 unicode 转换时,必须进行重写。
最右侧的指令用于当我们需要为某种类型的数据条目保留空间时,而不需要指定其具体值。例如,如果我们想为 12 个 32 位整数(标记为my_array)预留空间,那么我们会写如下代码:
my_array rd 12
汇编器将为这个数组保留 48 个字节,从代码中标记为my_array的位置开始。
尽管大部分时间你会在数据段中使用这些指令,但它们可以放置在任何地方。例如,你可以(出于任何目的)在一个过程内部、两个过程之间保留一些空间,或者包含一个包含预编译代码的二进制文件。
一个调试器
我们几乎准备好开始指令集探索的过程了;然而,还有一件事情我们还没有涉及,因为没有必要--调试器。市面上有相对较多的调试器可供选择,作为开发者,你很可能至少使用过其中一个。然而,由于我们对调试用汇编语言编写的程序感兴趣,我建议选择以下之一:
-
IDA Pro (
www.hex-rays.com/products/ida/index.shtml):非常方便,但也非常昂贵。如果你有它,那很好!如果没有,没关系,我们还有其他选择。仅适用于 Windows。 -
OllyDbg (
www.ollydbg.de/version2.html):免费调试器/反汇编器。对我们所需的内容已经足够了。仅适用于 Windows。不幸的是,该工具的 64 位版本从未完成,这意味着你无法将其用于 64 位示例。 -
HopperApp (
www.hopperapp.com):商业化,但价格非常实惠的反汇编器,带有 GDB 前端。macOS X 和 Linux。 -
GDB(GNU 调试器):免费提供,在 Windows、Linux、mac OS X 等系统上运行。虽然 GDB 是一个命令行工具,但使用起来相当容易。唯一的限制是反汇编器的输出是 AT&T 语法。
你可以自由选择这些中的任何一个,或者选择列表中未提及的调试器(有相对较多的选择)。在选择调试器时只有一个重要因素需要考虑--你应该感到舒适,因为在调试器中运行代码,查看处理器寄存器或内存中发生的一切,将极大地增强你在汇编语言编写代码时的体验。
指令集摘要
我们终于到了有趣的部分--指令集本身。不幸的是,描述现代基于英特尔的处理器的每一条指令都需要一本单独的书,但由于已经有这样一本书(www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-instruction-set-reference-manual-325383.pdf),我们不会无谓地增加东西,而是集中在指令组而不是单个指令上。在本章末尾,我们将实现 AES128 加密以进行演示。
通用指令
通用指令执行基本操作,如数据移动、算术运算、流程控制等。它们按功能分组:
-
数据传输指令
-
二进制算术指令
-
十进制算术指令
-
逻辑指令
-
移位与旋转指令
-
位/字节操作指令
-
流程控制指令
-
字符串操作指令
-
ENTER/LEAVE 指令
-
标志控制指令
-
杂项指令
指令的分组与《Intel 软件开发者手册》中所述相同。
数据传输指令
数据传输指令,顾名思义,用于在寄存器之间或寄存器与内存之间传输数据。它们中的一些可能将立即数作为源操作数。以下示例说明了它们的使用。
push ebx *; save EBX register on stack*
mov ax, 0xc001 *; move immediate value to AX register*
movzx ebx, ax *; move zero-extended content of AX to EBX*
*; register*
*; EBX = 0x0000c001*
bswap ebx *; reverse byte order of EBX register*
*; EBX = 0x01c00000*
mov [some_address], ebx *; move content of EBX register to*
*; memory at 'some_address'*
*; content of 'some_address' =*
*; 0x01c00000*
*; The above two lines of code could have*
*; been replaced with:*
*; movbe [some_address], ebx*
pop ebx *; restore EBX register from stack*
让我们更仔细地看一下与示例一起使用的指令:
-
PUSH:该指令要求处理器将操作数的值存储到堆栈中,并递减堆栈指针(32 位系统中是 ESP 寄存器,64 位系统中是 RSP 寄存器)。
-
MOV:这是最常用的数据传输指令:
-
它在相同大小的寄存器之间移动数据
-
它将立即数或从内存读取的值加载到寄存器中
-
它将寄存器的内容存储到内存中
-
它将立即数存储到内存中
-
-
MOVZX:这条指令在寻址模式上不如 MOV 强大,因为它只能在寄存器与寄存器之间或内存与寄存器之间传输数据,但它有一个特殊的功能——被传输的值会转换为更宽(使用更多位)的值,并且会进行零扩展。至于该指令支持的寻址模式,它只能执行以下操作:
-
它将字节值从寄存器或内存移动到字大小的寄存器,并用零扩展结果值(将添加一个字节)
-
它将字节值从寄存器或内存移动到一个双字节大小的寄存器,在这种情况下,原始值将添加三个字节,并用零扩展该值
-
它将字节大小的值从寄存器或内存移动到双字节大小的寄存器中,添加两个字节并用 0 的扩展值填充
-
-
MOVSX 类似于 MOVZX;然而,扩展位被源操作数的符号位填充。
-
BSWAP/MOVBE:BSWAP 指令是切换值的字节序最简单的方法;然而,它实际上并不是一条传输指令,因为它仅在寄存器内重新排列数据。BSWAP 指令仅适用于 32 位/64 位操作数。MOVBE 是一条更方便的字节顺序交换指令,因为它不仅可以交换字节顺序,还可以在操作数之间移动数据。该指令适用于 16 位、32 位和 64 位操作数,但无法在寄存器之间移动数据。
-
POP:此指令从栈中检索先前存储的值。此指令的唯一操作数是值应存储的目标,可以是寄存器或内存位置。此指令还会增加栈指针寄存器。
二进制算术指令
这些指令执行基本的算术操作。操作数可以是字节、字、双字或四字寄存器、内存位置或立即数。它们都会根据操作结果修改 CPU 标志,这反过来允许我们根据某些标志的值改变执行流程。
让我们来看几个基本的算术指令:
- INC:这是增量的缩写。此指令将 1 加到其操作数的值上。显然,
inc指令或其对应的dec指令不能与立即数一起使用。inc指令会影响某些 CPU 标志。例如,考虑我们取一个寄存器(为了简化起见,假设是 EAX 寄存器),将其设置为 0,并执行如下操作:
inc eax
在这种情况下,EAX 的值将为 1,ZF(零标志,记得吗?)将被设置为 0,这意味着操作结果是一个非零值。另一方面,如果我们将 EAX 寄存器加载为 0xffffffff,并使用 inc 指令将其增量 1,则寄存器将变为零,并且由于零是最新操作的结果,ZF 将被设置(值为 1)。
-
ADD:该指令执行简单的加法操作,将源操作数加到目标操作数,并将结果存储在目标操作数中。此指令还会影响几个 CPU 标志。在以下示例中,我们将
0xffffffff加到已设置为1的 EBX 寄存器。此操作的结果将是一个 33 位的值,但由于我们只能用 32 位存储结果,多余的一位将进入进位标志。此机制不仅对控制执行流程有用,还可以在加法操作两个大数时使用(可能是几百位数),因为我们可以通过较小的部分(例如 32 位)来处理这些数字。 -
ADC:谈到大数加法,
adc指令允许我们将由先前操作设置的进位标志的值,添加到额外两个值的和中。例如,如果我们想要加0x802597631和0x4fe013872,我们首先将0x02597631和0xfe013872相加,结果是0x005aaea3,并且进位标志被设置。接下来,我们将加上 8、4 和进位标志的值:
*;Assuming EAX equals to 8 and EBX equals to 4*
adc eax, ebx
这将得到 8 + 4 + 1(其中 1 是隐式操作数——CF 的值)= 0xd,因此,最终结果将是 0xd005aaea3。
以下示例更详细地说明了这些指令:
mov eax, 0 *; Set EAX to 0*
mov ebx, eax *; Set EBX to 0*
inc ebx *; Increment EBX*
*; EBX = 1*
add ebx, 0xffffffff *; add 4294967295 to EBX*
*; EBX = 0 and Carry Flag is set*
adc eax, 0 *; Add 0 and Carry Flag to EAX*
*; EAX = 1*
十进制算术指令
在大约 15 年的汇编语言开发和反向工程软件过程中,我只遇到过这些指令一次,那是在大学时。然而,提到它们是正确的,原因有几个:
-
像 AAM 和 AAD 这样的指令有时会作为乘法和除法的较小变体使用,因为它们允许立即操作数。它们较小,因为它们的编码方式可以生成更小的代码。
-
像 AAD 0(即除以零)这样的指令可以用作某些保护方案中的异常触发器。
-
不提及这些指令将是历史性的错误。
十进制算术指令在 64 位平台上是非法的。
首先,什么是 BCD?它是二进制编码十进制 (BCD),实际上是为了简化将数字的二进制表示转换为其 ASCII 等效值,反之亦然,同时增加了对以十六进制形式表示的十进制数执行基本算术操作的能力(不是它们的十六进制等价物!)。
BCD 有两种类型:压缩 BCD 和非压缩 BCD。压缩 BCD 使用单字节的 nibbles 来表示十进制数。例如,数字 12 将表示为 0x12。另一方面,非压缩 BCD 使用字节表示单独的数字(例如,12 转换为 0x0102)。
然而,考虑到这些指令自首次出现以来并未发生变化,它们仅作用于存储在单个字节中的值(对于压缩 BCD)或存储在单个字中的值(对于非压缩 BCD)。更重要的是,这些值应仅存储在 AL 寄存器中(对于压缩 BCD),或存储在 AX 寄存器中(更精确地说,是存储在 AH:AL 对寄存器中,针对非压缩 BCD)。
只有六个 BCD 指令:
- 加法后的十进制调整 (DAA):该指令专用于压缩 BCD。由于两个压缩 BCD 数字的加法结果不一定是有效的压缩 BCD 数字,因此调用 DAA 可以通过进行必要的调整,将结果转换为正确的压缩 BCD 值。例如,让我们加上 12 和 18。通常结果是 30,但如果我们加上
0x12和0x18,结果将是0x2a。以下示例说明了此类计算的过程:
mov al, 0x12 *; AL = 0x12, which is packed BCD*
*; representation of 12*
add al, 0x18 *; Add BCD representation of 18,
; which would result in 0x2a*
daa *; Adjust. AL would contain 0x30 after this instruction,*
*; which is the BCD representation of 30*
- 减法后的十进制调整 (DAS):此指令在减去两个压缩 BCD 数字后执行类似的调整。让我们在前面的代码中再添加一些行(AL 仍然包含
0x30):
sub al, 0x03 *; We are subtracting 3 from 30, however,*
*; the result of 0x30 - 0x03*
*; would be 0x2d*
das *; This instruction sets AL to 0x27,
; which is the packed BCD*
*; representation of 27.*
- 加法后的 ASCII 调整 (AAA):此指令类似于 DAA,但它作用于非压缩 BCD 数字(即,AX 寄存器)。让我们来看以下示例,在其中我们仍然加上 18 到 12,但我们使用非压缩 BCD 来执行此操作:
mov ax, 0x0102 *; 0x0102 is the unpacked BCD representation of 12*
add ax, 0x0108 *; same for 18*
*; The result of the addition would be
; 0x020a - far from being 0x0300*
aaa *; Converts the value of AX register to 0x0300*
结果值可以通过加上0x3030轻松转换为 ASCII 表示。
- 减法后的 ASCII 调整 (AAS): 该指令类似于 DAS,但作用于解包的 BCD 数字。我们可以继续在前面的示例中添加代码(AX 寄存器仍然有
0x0300的值)。让我们减去 3,最终得到的结果应该是0x0207:
sub ax, 0x0003 *; AX now contains 0x02fd*
aas *; So we convert it to unpacked BCD*
*; representation, but...*
*; AX becomes 0x0107, but as we know,
; 30 - 3 != 17...*
那么,问题出在哪里呢?事实上,并没有出什么问题;只是 AAS 指令的内部实现导致了进位(如我们在调试器中所见,CF 标志确实被设置了),或者更确切地说,发生了借位。这就是为什么我们为了方便,最好做如下处理:
adc ah, 0 *; Adds the value of CF to AH*
最终结果为 0x0207,它是 27 的解包 BCD 表示——正是我们所期待的结果。
- 乘法后的 ASCII 调整 (AAM): 两个解包 BCD 数字相乘的结果,也需要进行某些调整,以使其成为解包 BCD 格式。但我们首先要记住的是这些操作所涉及的大小限制。由于我们仅限于 AX 寄存器,所以乘数的最大值是 9(或
0x09),意味着在 AX 中存储结果时,我们只能处理一个字节的乘数。假设我们想将 8 乘以 4(即0x08 * 0x04);自然,结果将是0x20(32 的十六进制表示),这远远不是一个解包 BCD 表示的数字。aam指令通过将 AL 寄存器的值转换为解包 BCD 格式并存储在 AX 中来解决这个问题:
mov al, 4
mov bl, 8
mul bl *; AX becomes 0x0020*
aam *; Converts the value of AX to the*
*; corresponding unpacked BCD form. Now the AX*
*; register equals to 0x0302*
如我们所见,两字节解包 BCD 的相乘结果是一个解包 BCD 字。
- 除法前的 ASCII 调整 (AAD): 如同指令的名称所示,它应该在除法之前调整 AX 寄存器的值。其大小限制与 AAM 中相同。前一个示例后,AX 寄存器仍包含
0x0302,所以我们来将其除以 4:
mov bl, 4
aad *; Adjust AX. The value changes from 0x0302 to 0x0020*
div bl *; Perform the division itself*
*; AL register contains the result - 0x08*
如我们所见,尽管这些指令看似有点方便,但在数字之间转换 ASCII 表示法和二进制等价物时,有更好的方法,更不用说常规算术指令使用起来要方便得多了。
逻辑指令
这一组指令包含了位操作逻辑运算,这些你作为开发者肯定已经知道。这些包括 NOT、OR、XOR 和 AND 运算。然而,虽然高级语言区分位运算符和逻辑运算符(例如,在 C 中,位与 (&) 和逻辑与 (&&)),但它们在汇编层面上是相同的,并且通常与 EFlags 寄存器(或 64 位系统上的 RFlags)一起使用。
例如,考虑以下 C 语言的简单代码片段,它检查某个特定位是否已设置,并根据条件执行某些代码:
if(my_var & 0x20)
{
*// do something if true*
}
else
{
*// do something else otherwise*
}
它可以这样在汇编中实现:
and dword [my_var], 0x20 *; Check for sixth bit of 'my_var'.*
*; This operation sets ZF if the result*
*; is zero (if the bit is not set).*
jnz do_this_if_true *; Go to this label if the bit is set*
jmp do_this_if_false *; Go to this label otherwise*
这些指令的众多其他应用之一是有限域算术,在其中 XOR 代表加法,AND 代表乘法。
移位和旋转指令
这一组指令允许我们在目标操作数内移动位,这是高级语言中仅部分支持的功能。我们可以移位,但不能旋转,也不能隐式指定算术移位(算术移位或逻辑移位的选择通常由高级语言实现,依据操作的数据类型决定)。
使用移位指令,除了它们主要的作用是将位向左或向右移动一定位置外,它也是一种执行目标操作数乘除以 2 的幂的整数乘除法的简便方法。此外,还有两条特殊的移位指令,允许我们将一定数量的位从一个位置移动到另一个位置——更精确地说,是从一个寄存器移动到另一个寄存器或内存位置。
旋转指令允许我们,如其名称所示,将位从目标操作数的一端旋转到另一端。值得一提的是,位可以通过 CF(进位标志位)进行旋转,这意味着被移出的位会存储到 CF 中,同时 CF 的值会被旋转到操作数的另一侧。我们来看下面的例子,这是最简单的完整性控制算法之一:CRC8:
poly = 0x31 *; The polynomial used for CRC8 calculation*
xor dl, dl *; Initialise CRC state register with 0*
mov al, 0x16 ; *Prepare the sequence of 8 bits (may definitely*
*; be more than 8 bits)*
mov ecx, 8 *; Set amount of iterations*
crc_loop:
shl al, 1
rcl bl, 1
shl dl, 1
rcl bh, 1
xor bl, bh
test bl, 1
jz .noxor
xor dl, poly
.noxor:
loop crc_loop
前面的代码段中的循环体故意没有添加注释,因为我们希望更详细地观察那里发生了什么。
循环的第一条指令shl al, 1将我们正在计算 CRC8 值的最重要位移出,并将其存储到 CF 标志位中。接下来的指令rcl bl, 1将 CF(我们从比特流中移出的位)的值存入 BL 寄存器。接下来的两条指令做同样的事情,将最重要的位存入 DL 寄存器并保存到 BH 寄存器。rcl指令的副作用是,BL 和 BH 寄存器中的最重要位被移到 CF 标志位中。虽然在这个特定的例子中这并不重要,但在旋转 CF 标志位时我们应该记住这一点。最终,这意味着在 8 次迭代后,前面的代码为我们提供了0x16(即0xE5)的 CRC8 值,并将其存储在 DL 寄存器中。
示例中提到的两个移位和旋转指令有它们右侧的对应指令:
-
SHR:这会将位向右移,同时将最后移出的位保存在 CF 中。
-
RCR:这通过进位标志位将位旋转到右边。
还有一些我们不能跳过的额外指令:
-
SAR:这会将位移向右,同时“拖动”符号位,而不是简单地用零填充“空缺”的位。
-
SAL:这是一个算术左移。它不是真正的指令,而是为了方便程序员使用的助记符。汇编程序会生成与 SHL 相同的编码。
-
ROR:这会将位向右旋转。每个被右移的位都被移入左侧,并且也存储在 CF 中。
最后,正如前面提到的,两个特殊的移位指令如下:
-
SHLD:将一定数量的左侧(最高有效)位从一个寄存器移入另一个寄存器或内存位置。
-
SHRD:将一定数量的右侧(最低有效)位从一个寄存器移入另一个寄存器或内存位置。
之前示例中的另一个新指令是 TEST,但它将在下一节中解释。
位与字节指令
这一组指令是让我们能够在操作数内操作单个位和/或根据 EFlags/RFlags 寄存器中的标志状态设置字节的指令。
在实现位字段的高级语言中,即使我们想执行比仅仅扫描、测试、设置或重置更复杂的操作,也很容易访问单个位,正如 Intel 汇编语言提供的那样。然而,对于没有位字段的高级语言,我们必须实现某些构造,以便能够访问单个位,这也是汇编语言更为方便的地方。
虽然位和字节指令可能有多种应用,但让我们在 CRC8 示例的上下文中考虑它们(仅仅是其中几个)。说这些指令在该示例中会显著优化它并不完全正确;毕竟,它只会让我们去掉一条指令,使得算法的实现看起来更清晰。我们来看看crc_loop会如何变化:
crc_loop:
shl al, 1 *; Shift left-most bit out to CF*
setc bl *; Set bl to 1 if CF==1, or to zero otherwise*
shl dl, 1 *; shift left-most bit out to CF*
setc bh *; Set bh to 1 if CF==1, or to zero otherwise*
xor bl, bh *; Here we, in fact, are XOR'ing the previously left-most bits of al and dl*
jz .noxor *; Do not add POLY if XOR result is zero*
xor dl, poly
.noxor:
loop crc_loop
上述代码非常直观,但让我们更详细地了解一下这一组位指令:
-
BT:将目标操作数(位基)中的一位存储到 CF。该位通过源操作数中指定的索引来标识。
-
BTS:这与 BT 相同,但它还会设置目标操作数中的位。
-
BTR:这与 BT 相同,但它还会重置目标操作数中的位。
-
BTC:这与 BT 相同,但它还会反转(补码)目标操作数中的位。
-
BSF:这代表位扫描前移。它会在源操作数中查找设置的最低有效位。如果找到,该位的索引将返回到目标操作数中。如果源操作数全为零,则目标操作数的值未定义,并且 ZF 被置为 1。
-
BSR:这代表位扫描反向。它会在源操作数中查找设置的最高有效位。如果找到,该位的索引将返回到目标操作数中。如果源操作数全为零,则目标操作数的值未定义,并且 ZF 被置为 1。
-
TEST:此指令使得可以同时检查多个位是否被设置。简而言之,TEST 指令执行逻辑与运算,设置相应的标志,并丢弃结果。
字节指令的格式通常为 SETcc,其中cc表示条件码。以下是 Intel 平台上的条件码,参照《Intel 64 和 IA-32 架构软件开发者手册 第 1 卷 附录 B EFlags 条件码》的 B.1 条件码部分:
| 助记符 (cc) | 测试条件 | 状态标志设置 |
|---|---|---|
| O | 溢出 | OF = 1 |
| NO | 无溢出 | OF = 0 |
| B NAE | 小于 既不大于也不等于 | CF = 1 |
| NB AE | 不小于或等于 | CF = 1 |
| E Z | 等于 零 | ZF = 1 |
| NE NZ | 不等于 不为零 | ZF = 0 |
| BE NA | 小于或等于 不大于 | (CF 或 ZF) = 1 |
| NBE A | 既不小于也不等于 大于 | (CF 或 ZF) = 0 |
| S | 符号 | SF = 1 |
| NS | 无符号 | SF = 0 |
| P PE | 奇偶校验 偶校验 | PF = 1 |
| NP PO | 无奇偶校验 奇校验 | PF = 0 |
| L NGE | 小于 既不大于也不等于 | (SF xor OF) = 1 |
| NL GE | 不小于 大于或等于 | (SF xor OF) = 0 |
| LE NG | 小于或等于 不大于 | ((SF xor OF) 或 ZF) = 1 |
| NLE G | 不小于或等于 大于 | ((SF xor OF) 或 ZF) = 0 |
所以,通过前面的表格和 CRC8 示例中的setc指令,我们可以得出结论:它指示处理器在 C 条件为真时将bl(和bh)设置为 1,即 CF == 1。
执行流程转移指令
这一组指令使得无论是依据 EFlags/RFlags 寄存器中指定的特定条件,还是完全无条件的,执行流程都可以轻松地进行分支,因此可以将其分为两组:
-
无条件执行流程转移指令:
-
JMP:执行无条件跳转到明确指定的位置。这会将指令指针寄存器加载为指定位置的地址。
-
CALL:此指令用于调用一个过程。它将下一条指令的地址推送到栈中,并将指令指针加载为被调用过程中的第一条指令地址。
-
RET:此指令用于从过程返回。它将栈中存储的值弹出到指令指针寄存器。当在过程末尾使用时,它将执行返回到 CALL 指令后的指令。
RET 指令可能会有一个 2 字节的操作数,在这种情况下,该值定义了在栈上传递给过程的操作数占用的字节数。然后,栈指针会通过加上字节数自动调整。
-
INT:此指令触发软件中断。
在 Windows 上编程时,在环 3 中使用此指令相当罕见。甚至可以安全地假设唯一的使用场景是 INT3——软件断点。然而,在 32 位 Linux 上,它用于调用系统调用。
-
-
条件执行流转移指令:
-
Jcc:这是 JMP 指令的条件变种,其中cc代表条件码,可以是前面表格中列出的条件码之一。例如,查看 CRC8 示例中的
jz .noxor行。 -
JCXZ:这是条件跳转指令的特殊版本,使用 CX 寄存器作为条件。只有当 CX 寄存器的值为 0 时,跳转才会执行。
-
JECXZ:这与上面相同,但它作用于 ECX 寄存器。
-
JRCXZ:这与上面相同,但它作用于 RCX 寄存器(仅限长模式)。
-
LOOP:一个以 ECX 作为计数器的循环,这将递减 ECX,并且如果结果不为 0,则将指令指针寄存器加载为循环标签的地址。我们已经在 CRC8 示例中使用了这个指令。
-
LOOPZ/LOOPE:这是一个以 ECX 作为计数器的循环,前提是 ZF = 1。
-
LOOPNZ/LOOPNE:这是一个以 ECX 作为计数器的循环,前提是 ZF = 0。
-
为了举例说明,我们实现 CRC8 算法作为一个过程(将以下代码插入到相关 32 位模板的代码部分):
*;*
*; Put your code here*
*; *
mov al, 0x16 *; In this specific case we pass the
; only argument via AL register*
call crc_proc *; Call the 'crc_proc' procedure*
*; For Windows*
push 0 *; Terminate the process if you are on Windows*
call [exitProcess]
*; For Linux ; Terminate the process if you are on Linux*
xor ebx, ebx
mov eax, ebx
inc eax
int 0x80
crc_proc: *; Our CRC8 procedure*
push ebx ecx edx *; Save the register we are going to use on stack*
xor dl, dl *; Initialise the CRC state register*
mov ecx, 8 *; Setup counter*
.crc_loop:
shl al, 1
setc bl
shl dl, 1
setc bh
xor bl, bh
jz .noxor
xor dl, 0x31
.noxor:
loop .crc_loop
mov al, dl *; Setup return value*
pop edx ecx ebx *; Restore registers*
ret *; Return from this procedure*
字符串指令
这是一个有趣的指令组,操作的是字节、字、双字或四字的字符串(仅限长模式)。这些指令只有隐式操作数:
-
源地址应加载到 ESI 寄存器中(长模式下为 RSI 寄存器)
-
目标地址应加载到 EDI 寄存器中(长模式下为 RDI 寄存器)
-
所有指令中,除了 MOVS和 CMPS指令之外,都使用了 EAX(例如,AL 和 AX)寄存器的某个变体。
-
迭代次数(如果有的话)应位于 ECX 中(仅与 REP*前缀一起使用)
对于字节数据,ESI 和/或 EDI 寄存器自动增加 1;对于字数据,增加 2;对于双字数据,增加 4。这些操作的方向(增或减 ESI/EDI)由 EFlags 寄存器中的方向标志(DF)控制:DF = 1:递减 ESI/EDI,DF = 0:递增 ESI/EDI。
这些指令可以分为五组。实际上,更准确地说,有五个指令,每个指令支持四种数据大小:
-
MOVSB/MOVSW/MOVSD/MOVSQ:这些指令将内存中的字节、字、双字或四字从由 ESI/RSI 指向的位置移动到由 EDI/RDI 指向的位置。指令的后缀指定要移动的数据大小。将 ECX/RCX 设置为要移动的数据项数量,并在其前加上 REP前缀,指示处理器执行该指令 ECX 次,或者在使用 REP前缀的条件(如果有的话)为真时执行。
-
CMPSB/CMPSW/CMPSD/CMPSQ:这些指令将 ESI/RSI 寄存器指向的数据与 EDI/RDI 寄存器指向的数据进行比较。迭代规则与 MOVS* 指令相同。
-
SCASB/SCASW/SCASD/SCASQ:这些指令扫描由 EDI/RDI 寄存器指向的数据项序列(其大小由指令的后缀指定),查找存储在 AL、AX、EAX 或 RAX 中的值,具体取决于操作模式(保护模式或长模式)和指令的后缀。迭代规则与 MOVS* 指令相同。
-
LODSB/LODSW/LODSD/LODSQ:这些指令将 AL、AX、EAX 或 RAX(取决于操作模式和指令的后缀)从内存中加载值,该值由 ESI/RSI 寄存器指向。迭代规则与 MOVS* 指令相同。
-
STOSB/STOSW/STOSD/STOSQ:这些指令将 AL、AX、EAX 或 RAX 寄存器的值存储到由 EDI/RDI 寄存器指向的内存位置。这些迭代规则与 MOVS* 指令相同。
所有前面的指令都有没有后缀的显式操作数形式,但在这种情况下,我们需要指定操作数的大小。虽然操作数本身不会改变,因此始终是 ESI/RSI 和 EDI/RDI,但我们可以改变的只是操作数的大小。以下是这种情况的示例:
scas byte[edi]
以下示例展示了 SCAS* 指令的典型用法——扫描一个字节序列(在此特定情况下)以查找存储在 AL 寄存器中的特定值。其他指令的使用方法类似。
*; Calculate the length of a string*
mov edi, hello
mov ecx, 0x100 *; Maximum allowed string length*
xor al, al *; We will look for 0*
rep scasb *; Scan for terminating 0*
or ecx, 0 *; Check whether the string is too long*
jz too_long
neg ecx *; Negate ECX*
add ecx, 0x100 *; Get the length of the string*
*; ECX = 14 (includes terminating 0)*
too_long:
*; Handle this*
hello db "Hello, World!", 0
rep 前缀,在前面的示例中使用,表示处理器应使用 ECX 寄存器作为计数器来执行带前缀的命令(就像它在 LOOP* 指令中使用一样)。但是,还有一个由 ZF(零标志)指定的可选条件。这样的条件由附加在 REP 后面的条件后缀指定。例如,使用 E 或 Z 后缀会指示处理器在每次迭代之前检查 ZF 是否已设置。后缀 NE 或 NZ 会指示处理器在每次迭代之前检查 ZF 是否已重置。考虑以下示例:
repz cmpsb
这将指示处理器在两个字节序列相等且 ECX 不为零时,持续比较由 EDI/RDI 和 ESI/RSI 寄存器指向的字节序列。
ENTER/LEAVE
根据英特尔开发者手册,*这些指令为块结构语言中的过程调用提供机器语言支持;*然而,它们对汇编开发者同样非常有用。
在实现一个过程时,我们必须处理栈帧的创建,存储过程变量,存储 ESP 的值,然后在离开过程之前恢复这些内容。这两条指令可以为我们完成所有这些工作:
*; Do something here*
call my_proc
*; Do something else here*
my_proc:
enter 0x10, 0 *; Save EBP register on stack,*
*; save ESP to EBP and*
*; allocate 16 bytes on stack for procedure variables*
*;*
*; procedure body*
*;*
leave *; Restore ESP and EBP registers (this automatically*
*; releases the space allocated on stack with ENTER)*
ret *; Return from procedure*
上述代码等价于以下代码:
*; Do something here*
call my_proc
*; Do something else here*
my_proc:
push ebp *; Save EBP register on stack,*
mov ebp, esp *; save ESP to EBP and*
sub esp, 0x10 *; allocate 16 bytes on stack for procedure variables*
*;*
*; procedure body*
*;*
mov esp, ebp *; Restore ESP and EBP registers (this automatically*
pop ebp *; releases the space allocated on stack with ENTER)*
ret *; Return from procedure*
标志控制指令
EFlags 寄存器包含有关最后一次 ALU 操作的某些信息以及 CPU 的某些设置(例如,字符串指令的方向);然而,我们有机制通过以下指令控制该寄存器的内容,甚至是单个标志:
-
设置/清除进位标志 (STC/CLC):在某些操作之前,我们可能需要设置或重置 CF。
-
补充进位标志 (CMC):该指令反转 CF 的值。
-
设置/清除方向标志 (STD/CLD):我们可以使用这些指令来设置或重置 DF,以确定在字符串指令中 ESI/EDI(RSI/RDI)是递增还是递减。
-
将标志加载到 AH 寄存器 (LAHF):某些标志(例如 ZF)没有直接修改的相关指令,因此我们可以将 Flags 寄存器加载到 AH 中,修改相应的位,并用修改后的值重新加载 Flags 寄存器。
-
将 AH 寄存器存储到标志寄存器 (SAHF):该指令将 AH 寄存器的值存储到标志寄存器中。
-
设置/清除中断标志 (STI/CLI)(非用户空间):这些指令用于操作系统级别的中断使能/禁用。
-
将标志/EFlags/RFlags 寄存器推送到堆栈 (PUSHF/PUSHFD/PUSHFQ):LAHF/SAHF 指令可能不足以检查/修改 Flags/EFlags/RFlags 寄存器中的某些标志。通过使用 PUSHF* 指令,我们可以访问其他位(标志)。
-
从堆栈恢复标志/EFlags/RFlags 寄存器 (POPF/POPFD/POPFQ):这些指令将从堆栈中重新加载 Flags/EFlags/RFlags 寄存器的新值。
杂项指令
有一些指令没有特别指定的类别,具体如下:
- 加载有效地址 (LEA):该指令根据处理器的寻址模式,在源操作数中计算有效地址,并将其存储到目标操作数中。当寻址模式中指定的项需要计算时,它也常常作为 ADD 指令的替代使用。以下示例代码展示了这两种情况:
lea eax, [some_label] *; EAX will contain the address of some_label*
lea eax, [ebx + edi] *; EAX will contain the sum of EBX and EDI*
-
无操作 (NOP):顾名思义,该指令不执行任何操作,通常用于填充对齐过程之间的空白。
-
处理器识别 (CPUID):根据操作数(在 EAX 中)的值,该指令返回 CPU 的识别信息。只有当 EFlags 寄存器中的 ID 标志(第 21 位)被设置时,才可以使用该指令。
FPU 指令
FPU 指令由 x87 浮点单元 (FPU) 执行,处理浮点、整数或二进制编码十进制值。这些指令根据它们的用途进行分组:
-
FPU 数据传输指令
-
FPU 基本算术指令
-
FPU 比较指令
-
FPU 加载常量指令
-
FPU 控制指令
FPU 操作的另一个重要方面是,与处理器的寄存器不同,浮点寄存器是以堆栈的形式组织的。像fld这样的指令用于将操作数压入堆栈顶部,像fst这样的指令用于从堆栈顶部读取值,而像fstp这样的指令则用于将值从堆栈顶部弹出,并将其他值向顶部移动。
以下示例展示了计算半径为0.2345的圆的周长:
*; This goes in '.text' section*
fld [radius] *; Load radius to ST0*
*; ST0 <== 0.2345*
fldpi *; Load PI to ST0*
*; ST1 <== ST0*
*; ST0 <== 3.1415926*
fmulp *; Multiply (ST0 * ST1) and pop*
*; ST0 = 0.7367034*
fadd st0, st0 *; * 2*
*; ST0 = 1.4734069*
fstp [result] *; Store result*
*; result <== ST0*
*; This goes in '.data' section*
radius dt 0.2345
result dt 0.0
扩展
自从第一个 Intel 微处理器问世以来,技术发展显著,处理器架构的复杂性也大大增加。最初的一套指令,虽然现在仍然非常强大,但已无法满足某些任务的需求(在这里我们不得不承认,随着时间的推移,这类任务的数量正在增加)。Intel 采用的解决方案非常好,而且相当用户友好:指令集架构扩展(ISA 扩展)。从MMX(非官方地称为多媒体扩展)到 SSE4.2、AVX 和 AVX2 扩展,Intel 走了很长一段路,这些扩展引入了对 256 位数据处理的支持,以及 AVX-512,后者允许处理 512 位数据,并将可用的 SIMD 寄存器数量扩展到 32 个。所有这些都是 SIMD 扩展,SIMD 代表单指令多数据。在本节中,我们将特别关注 AES-NI 扩展,并部分关注 SSE(将在第五章中详细讲解,并行数据处理)。
AES-NI
AES-NI代表高级加密标准新指令,这是 Intel 在 2008 年首次提出的扩展,旨在加速 AES 算法的实现。
以下代码检查 CPU 是否支持 AES-NI:
mov eax, 1 *; CPUID request code #1*
cpuid
test ecx, 1 shl 25 *; Check bit 25*
jz not_supported *; If bit 25 is not set - CPU does not support AES-NI*
该扩展中的指令相对简单且数量较少:
-
AESENC:此指令对 128 位数据执行 AES 加密的一轮,使用 128 位轮密钥,适用于除最后一轮以外的所有加密轮次
-
AESENCLAST:此指令对 128 位数据执行 AES 加密的最后一轮
-
AESDEC:此指令对 128 位数据执行 AES 解密的一轮,使用 128 位轮密钥,适用于除最后一轮以外的所有解密轮次
-
AESDECLAST:此指令对 128 位数据执行 AES 解密的最后一轮
-
AESKEYGENASSIST:此指令帮助使用 8 位轮常量(RCON)生成 AES 轮密钥
-
AESIMC:此指令对 128 位轮密钥执行逆混合列转换
SSE
SSE 代表流式 SIMD 扩展,顾名思义,它允许通过单一指令处理多个数据,最典型的例子如下代码所示:
lea esi, [fnum1]
movq xmm0, [esi] *; Load fnum1 and fnum2 into xmm0 register*
add esi, 8
movq xmm1, [esi] *; Load fnum3 and fnum4 into xmm1 register*
addps xmm0, xmm1 *; Add two floats in xmm1 to another two floats in xmm0*
*; xmm0 will then contain:*
*; 0.0 0.0 1.663 12.44*
fnum1 dd 0.12
fnum2 dd 1.24
fnum3 dd 12.32
fnum4 dd 0.423
示例程序
如你所注意到,前两节(AES-NI 和 SSE)没有适当的示例。原因在于,展示这两种扩展功能的最佳方式是将它们混合在一个程序中。在这一节中,我们将借助这两个扩展实现一个简单的 AES-128 加密算法。AES 加密是一个经典的例子,显然会从 SSE 提供的数据并行处理中受益。
我们将使用在本章开头准备的模板,因此,我们只需要在这条评论的位置写下以下代码:
*;*
*; Put your code here*
*;*
代码在 Windows 和 Linux 上运行都一样,因此无需其他准备:
*; First of all we have to expand the key*
*; into AES key schedule.*
lea esi, [k]
movups xmm1, [esi]
lea edi, [s]
*; Copy initial key to schedule*
mov ecx, 4
rep movsd
*; Expand the key*
call aes_set_encrypt_key
*; Actually encrypt data*
lea esi, [s] *; ESI points to key schedule*
lea edi, [r] *; EDI points to result buffer*
lea eax, [d] *; EAX points to data we want*
*; to encrypt*
movups xmm0, [eax] *; Load this data to XMM0*
*; Call the AES128 encryption procedure*
call aes_encrypt
*; Nicely terminate the process*
push 0
call [exitProcess]
*; AES128 encryption procedure*
aes_encrypt: *; esi points to key schedule*
*; edi points to output buffer*
*; xmm0 contains data to be encrypted*
mov ecx, 9
movups xmm1, [esi]
add esi, 0x10
pxor xmm0, xmm1 *; Add the first round key*
.encryption_loop:
movups xmm1, [esi] *; Load next round key*
add esi, 0x10
aesenc xmm0, xmm1 *; Perform encryption round*
loop .encryption_loop
movups xmm1, [esi] *; Load last round key*
aesenclast xmm0, xmm1 *; Perform the last encryption round*
lea edi, [r]
movups [edi], xmm0 *; Store encrypted data*
ret
*; AES128 key setup procedures*
*; This procedure creates full*
*; AES128 encryption key schedule*
aes_set_encrypt_key: *; xmm1 contains the key*
*; edi points to key schedule*
aeskeygenassist xmm2, xmm1, 1
call key_expand
aeskeygenassist xmm2, xmm1, 2
call key_expand
aeskeygenassist xmm2, xmm1, 4
call key_expand
aeskeygenassist xmm2, xmm1, 8
call key_expand
aeskeygenassist xmm2, xmm1, 0x10
call key_expand
aeskeygenassist xmm2, xmm1, 0x20
call key_expand
aeskeygenassist xmm2, xmm1, 0x40
call key_expand
aeskeygenassist xmm2, xmm1, 0x80
call key_expand
aeskeygenassist xmm2, xmm1, 0x1b
call key_expand
aeskeygenassist xmm2, xmm1, 0x36
call key_expand
ret
key_expand: *; xmm2 contains key portion*
*; edi points to place in schedule*
*; where this portion should*
*; be stored at*
pshufd xmm2, xmm2, 0xff *; Set all elements to 4th element*
vpslldq xmm3, xmm1, 0x04 *; Shift XMM1 4 bytes left*
*; store result to XMM3*
pxor xmm1, xmm3
vpslldq xmm3, xmm1, 0x04
pxor xmm1, xmm3
vpslldq xmm3, xmm1, 0x04
pxor xmm1, xmm3
pxor xmm1, xmm2
movups [edi], xmm1
add edi, 0x10
ret
以下内容应放置在数据段/段落中:
*; Data to be encrypted*
d db 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xa,0xb, 0xc, 0xd, 0xe, 0xf
*; Encryption key*
k db 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
*; AES key schedule (11 round keys, 16 bytes each)*
s rb 16 * 11
*; Result will be placed here*
r rb 16
总结
我们以创建两个模板开始本章——一个用于 32 位 Windows 可执行文件,另一个用于 32 位 Linux 可执行文件。虽然这两个模板中有些部分可能仍不清楚,但请不要为此烦恼,因为我们会在适当的时候逐一讲解它们。你可以将这些模板作为自己代码的骨架。
本章最重要的部分,然而,是专门介绍了 Intel 指令集架构本身。当然,这只是一个非常简短的概述,因为没有必要描述每一条指令——Intel 通过发布其程序员手册完成了这项工作,该手册包含超过三千页内容。因此,决定只提供基本信息,帮助我们对 Intel 指令集有一个基本的了解。
本章的最后,我们借助 AES-NI 扩展实现了 AES128 加密算法,这使得 AES128 加密/解密过程变得显著更简单和更容易。
现在,当我们理解了这些指令后,我们准备继续深入学习内存组织以及数据和代码寻址模式。
第四章:内存寻址模式
到目前为止,我们已经对汇编编程的某些基本方面有所了解。我们已经覆盖了英特尔架构的基础,设置了您选择的开发环境,并且了解了 指令集架构(ISA)。
我们知道可以对不同类型的数据执行哪些操作,但只要不知道如何获取和存储数据,这些操作几乎没有任何价值。当然,我们熟悉 mov 指令,但如果不知道如何在内存中寻址数据,这条指令就毫无用处。
幸运的是,英特尔为我们提供了一种非常灵活的内存寻址机制。在本章中,我们将介绍以下几种内存寻址模式:
-
顺序寻址
-
直接寻址
-
通过立即数地址
-
通过存储在寄存器中的地址
-
-
间接寻址
-
通过一个由立即数指向的地址
-
通过一个由寄存器指向的地址
-
-
基础相对寻址
-
基础 + 索引
-
基础 + 索引 * 比例
-
-
基于 IP/RIP 的寻址
-
远指针
上述分类与英特尔如何分类寻址模式无关,因为我们并不关注指令内的地址编码。我们关注的是如何寻址内存,并能恰当地使用它们。值得一提的是,前面的列表代表了数据和代码的寻址模式。此外,本章将使用 64 位示例,以便能够涵盖这里列出的所有模式。
寻址代码
当我们说“寻址代码”时,我们指的是 CPU 解析下一条待执行指令的地址的方式,这取决于代码本身的逻辑,它告诉处理器是否应该顺序执行指令,或者跳转到其他位置。
顺序寻址
代码的默认寻址模式是 顺序寻址,当 指令指针(IP,32 位系统为 IP,64 位为 RIP)寄存器包含当前执行的指令后面的指令地址时。我们不需要做任何事情来将处理器设置为此模式。指令指针会由 CPU 自动设置为下一条指令的地址。
例如,在执行下面代码的第一条指令时,IP 已经设置为下一条指令的地址,标记为 next_instruction。由于第一条指令是 call,我们知道它会将返回地址压入堆栈——在这个特定情况下,返回地址也是 next_instruction 的地址——第二条指令(pop 指令)从堆栈中取回返回地址的值:
call next_instruction
next_instruction:
pop rax
*; the rest of the code*
前面的例子(或其变种)经常出现在不同的打包器和保护器的代码中,也被 shellcode 编写者作为创建位置无关代码的一种手段,在这种代码中,过程和变量的地址可以通过将它们的偏移量从next_instruction加到next_instruction的地址来计算。
直接寻址
直接寻址一词意味着指令中直接包含的地址作为操作数。一个例子可能是 远程调用/jmp。大多数 Windows 可执行文件被加载在地址 0x00400000,并且第一个部分(默认为代码部分)被加载在地址0x00401000。为了举个例子,假设我们有一个可执行文件,我们确定它被加载在上述地址,其代码部分位于基地址(即我们的可执行文件加载的地址)偏移量 0x1000 处,并且在第一部分的开头有某种特殊代码。假设它是一个错误处理程序,用于以正确的方式终止程序执行。在这种情况下,我们可以使用远程调用或远程跳转将执行流引导到该代码:
*; The following call would be encoded as (address is underlined):*
*; 0xff 0x1d 0x00 0x10 0x40 0x00*
call far [0x00401000]
*; or as*
*; 0xff 0x2d 0x00 0x10 0x40 0x00*
jmp far [0x00401000]
然而,更常见的例子是寄存器调用,其中目标地址存储在寄存器中:
lea rax, [my_proc]
call rax
在前面的代码中,我们将 RAX 寄存器加载了我们要调用的my_proc过程的地址,然后第二行是实际的调用。这样的模式,例如,编译器在将switch语句翻译为汇编时会使用,当特定情况下对应的代码地址要么从跳转表中加载,要么使用某个硬编码的基地址(它也可能在执行时被重新定位)和从跳转表中获取的偏移量来计算。
间接寻址
“间接寻址”一词相当直观。正如该模式的名称所示,地址在某个地方,但并不直接使用。相反,它是通过指针引用的,指针可以是寄存器或某个基地址(立即地址)。例如,以下代码会调用同一个过程两次。在第一次调用中,地址是通过存储在rax寄存器中的指针获取的,而在第二次调用中,我们使用一个存储我们要调用的过程地址的变量:
*; This goes into code section*
push my_proc
lea rax, [rsp]
call qword [rax]
add rsp, 8
call qword [my_proc_address]
*;*
*;*
my_proc:
ret
*; This goes into data section*
my_proc_address dq my_proc
正如我们所看到的,在这两种情况下,call指令的操作数是指向内存中存储my_proc过程地址的位置的指针。这个寻址模式可以用来增强代码片段执行流的混淆。
基于 RIP 的寻址
IP 或 RIP(取决于我们是在 32 位还是 64 位平台上)表示相对于指令指针寄存器的寻址。
这个寻址模式的最佳示例是call和jmp指令。例如,考虑以下代码:
call my_proc
*; or*
jmp some_label
这不会包含 my_proc 或 some_label 的地址。相反,call 指令将以一种方式编码,使得它的参数是从下一条指令到 my_proc 的偏移量。如我们所知,指令指针寄存器在处理器执行当前指令时包含下一条指令的地址;因此,我们可以肯定地说,目标地址是相对于指令指针的值计算的(32 位平台为 IP,64 位平台为 RIP)。
相同的规则适用于前面示例中的 jmp 指令——目标地址是相对于当前指令指针的值计算的,指令指针包含着下一条指令的地址。
数据寻址
数据寻址模式与代码寻址相同,唯一的例外是 32 位系统上的基于 IP 的寻址。
顺序寻址
是的,这并不是一个打字错误,当涉及到数据寻址时也存在顺序寻址,尽管它确实需要一些特定的设置。
请记住 RSI/RDI 配对(或者 32 位系统的 ESI/EDI),我们在第一章的 Intel 架构 和第三章的 Intel 指令集架构(ISA) 中都有提到。这个配对是顺序数据寻址的一个很好的例子,其中源地址和/或目标地址在每次使用这些寄存器(其中一个或两个)的指令执行后自动递增或递减(取决于方向标志的值)。
以下示例通过将文本字符串从数据段的位置复制到栈上分配的缓冲区来说明这种模式:
*; This portion goes into the code section.*
*; Assuming the RBP register contains the stack frame address*
*; and the size of the frame is 0x50 bytes.*
lea rdi, [rbp – 0x50]
lea rsi, [my_string]
mov ecx, my_string_len
rep movsb
*; And this portion goes into the data section*
my_string db ‘Just some string’,0
my_string_len = $ - my_string
如我们所见,RDI 寄存器被加载为栈帧中的最低地址,RSI 寄存器被加载为字符串的地址,RCX 寄存器被加载为字符串的长度,包括终止零。之后,每次执行 rep movsb 行时,RSI 和 RDI 会顺序递增(递增的大小取决于我们记得的 movs* 变体——对于 movsb 为 1,movsw 为 2,movsd 为 4,movsq 在 64 位平台上为 8)。
直接寻址
就像在代码寻址的情况下,这种模式意味着源操作数或目标操作数(取决于指令和意图)的地址被明确指定。然而,与代码寻址不同,我们能够指定地址本身,除非先将其加载到寄存器中。考虑将变量的值加载到寄存器中或从寄存器存储到内存中的示例:
mov al, [name_of_variable]
*; or*
mov [name_of_another_variable], eax
在这两种情况下,name_of_variable 和 name_of_another_variable 都被汇编器转换为这些变量的地址。当然,我们也可以使用寄存器来实现此目的。以下示例演示了一个 if…else 语句:
*; This goes into code section.*
xor rax, rax
*; inc rax ; Increment RAX in order to call the second procedure*
lea rbx, [indices]
add rax, rbx
lea rbx, [my_proc_address]
add bl, [rax]
mov rbx, [rbx]
call qword rbx
*; The rest of the code*
align 8
my_proc0:
push rbp
mov rbp, rsp
xor eax, eax
mov rsp, rbp
pop rbp
ret
align 8
my_proc1:
push rbp
mov rbp, rsp
xor eax, eax
inc eax
mov rsp, rbp
pop rbp
ret
*; And the following goes into data section*
indices db 0, 8
align 8
my_proc_address dq my_proc0, my_proc1
代码的第一行将 rax 寄存器设置为零,当第二行被注释掉时,这会导致代码调用 my_proc0。另一方面,如果我们取消注释 inc rax 指令,则会调用 my_proc1。
比例、索引、基址和位移
这是一种非常灵活的寻址模式,因为它允许我们以类似于在数组中寻址数据的方式来寻址内存,这是我们都熟悉的。尽管这种寻址模式通常被称为比例/索引/基址(省略了位移部分),但我们并不强制同时使用所有元素,我们将进一步看到,比例/索引/基址/位移方案常常简化为基址、基址 + 索引或位移 + 索引。后两者可能带或不带比例。但首先,让我们看看谁是谁,哪个部分代表什么:
-
位移:从技术上讲,这是相对于某个段基址的整数偏移(默认是 DS)。
-
基址:这是一个寄存器,包含相对于位移的数据偏移,或者如果没有指定位移,则包含数据起始地址(实际上,当我们未指定位移时,汇编器会自动加上一个零位移)。
-
索引:这是一个寄存器,包含相对于基址 + 位移的数据偏移。这类似于索引或数组成员。
-
规模:CPU 并没有数据类型的概念;它只理解大小。因此,如果我们操作的值大于 1 字节,我们必须适当地调整索引值的比例。对于字节、字、双字和四字,比例分别为 1、2、4 或 8。显然,没有必要明确指定比例为 1,因为如果未指定比例,它是默认值。
可以通过在地址前加上段前缀显式指定另一个段(例如,cs: 用于 CS,es: 用于 ES,等等)。
为了计算最终地址,处理器会取段的基址(默认是 DS),加上位移,再加上基址,最后通过加上索引乘以比例来完成计算:
段基址 + 位移 + 基址 + 索引 * 比例
理论上,这一切看起来既简单又清晰,那么我们继续向实践推进,这也更好,且容易得多。如果我们再看一遍直接寻址的示例代码,我们可能会看到其中有几行完全多余的代码。以下是我们要处理的第一行:
mov rbx, [rbx]
尽管它提供了一个很好的基于寄存器的直接寻址示例,但它可以安全地省略,接下来的指令(call)应更改为(记得间接调用吗?):
call qword [rbx]
然而,即使这一行也可以像大多数调用代码一样省略。仔细观察问题,我们看到有一个过程指针数组(实际上是一个包含两个元素的数组)。从高级语言的角度来看,以下代码的目的是:
int my_proc0()
{
return 0;
}
int my_proc1()
{
return 1;
}
int call_func(int selector)
{
int (*funcs[])(void) = {my_proc0, my_proc1};
return funcs[selector]();
}
英特尔架构为基址 + 索引的数组式数据/代码寻址提供了类似的接口,但它引入了方程中的另一个成员——倍增。由于汇编器,尤其是处理器并不关心我们操作的数据类型,我们必须自己帮助它们。
虽然基址部分(无论是标签还是存放地址的寄存器)由处理器视为内存中的地址,索引只是需要加到基址上的字节数,但在这种特定情况下,我们当然可以自己对索引进行缩放,因为算法相当简单。我们只有两个选择器的可能值(在前面的汇编代码中是 rax 寄存器),即 0 和 1,因此我们可以例如将 rbx 寄存器加载为 my_proc_address 的地址:
lea rbx, [my_proc_address]
然后,我们将 rax 寄存器向左移三次(这样做相当于乘以 8,因为我们在 64 位架构上,地址是 8 字节长的,否则我们会指向 my_proc0 地址的第二个字节),并将结果加到 rbx 寄存器中。这对单次迭代来说可以,但对于频繁执行的代码来说并不太方便。即使我们使用一个额外的寄存器来存储 rbx 和 rax 的和——如果我们需要那个寄存器做其他事情怎么办?
这时,倍增部分就发挥作用了。将汇编示例中的调用代码重写,将得到以下结果:
xor rax, rax
*; inc rax ; increment RAX to call the second procedure*
lea rbx, [my_proc_address]
call qword [rbx + rax * 8]
*; or even a more convenient one*
xor rax, rax
*; inc rax*
call qword[my_proc_address + rax * 8]
当然,基址/索引/倍增模式可以用于寻址任何类型的数组,不一定是函数指针数组。
RIP 寻址
基于 RIP(在 64 位平台上的指令指针寄存器)寻址数据是在 64 位架构中引入的,它能够生成更加紧凑的代码。这种寻址模式与基址/索引/倍增模式的思想相同,只不过这时指令指针作为基址使用。
例如,如果我们想将某个寄存器加载为一个变量的地址,我们可以在汇编中写下以下代码:
lea rbx, [my_variable]
汇编器会自动进行所有的调整,最终指令的编码结果将与以下内容等效:
lea rbx, [rip + (my_variable – next_instruction)]
将 rbx 寄存器加载为 rip 寄存器的值(即下一个指令的地址)加上一个变量相对于下一个指令地址的字节偏移量。
远指针
可以相对安全地说,远指针已经属于过去的历史了,尤其是在应用开发层面;然而,如果在这里不提及它们也是不对的,毕竟我们仍然能用它做一些有用的事情。简单来说,远指针结合了段选择器和段内偏移量。远指针起源于 16 位操作模式时代,经历了 32 位保护模式,最终进入了长模式,尽管它们几乎不再相关,尤其是在长模式下,所有内存都被视为一个平坦的数组,我们几乎不可能再使用它们。
用于将远指针加载到段寄存器的指令(一些已过时):常用寄存器对如下:
-
LDS:这将远指针的选择子部分加载到 DS 寄存器。
-
LSS:这将远指针的选择子部分加载到 SS 寄存器。
-
LES:这将远指针的选择子部分加载到 ES 寄存器。
-
LFS:这将远指针的选择子部分加载到 FS 寄存器。
-
LGS:这将远指针的选择子部分加载到 GS 寄存器。
然而,让我们看看如何利用它们。为了简化起见,我们将考虑一个短小的 32 位 Windows 示例,在该示例中我们获取进程环境块(PEB)的地址:
*; This goes into the code section*
mov word [far_ptr + 4], fs *; Store FS selector to the selector part of the far_ptr*
lgs edx, [far_ptr] *; Load the pointer*
mov eax, [gs:edx] *; Load EAX with the address of the TIB*
mov eax, [eax + 0x30] *; Load EAX with the address of the PEB*
*; This goes into the data section*
far_ptr dp 0 *; Six bytes far pointer:*
*; four bytes offset*
*; two bytes segment selector*
正如你所看到的,这段代码在这个例子中是相当冗余的,因为我们已经将正确的选择子加载到了 FS 寄存器中,但它仍然展示了机制。在现实世界中,没有人会采取这种方式来获取 PEB 的地址;相反,应该会执行以下指令:
mov eax, [fs:0x30]
这将会把eax寄存器加载为 PEB 的地址,因为fs:0x00000000已经是指向 TIB 的远指针。
LDS 和 LES 指令(分别用于 DS 和 ES 寄存器)已过时。
总结
本章我们简要介绍了现代 Intel CPU 的寻址模式。有些资源定义了更多的寻址模式,但让我重申,作为奥卡姆剃刀的忠实拥护者,我认为没有理由在没有必要的情况下增加不必要的东西,因为大多数额外的模式只是对上面已解释的模式的变种。
到目前为止,我们已经看到了如何对代码和数据进行寻址,这基本上就是汇编语言编程的精髓。正如你在阅读本书并亲自尝试代码时会看到的,编写汇编程序的至少 90%是编写如何移动数据,数据来自哪里,去往哪里(剩下的 10%是实际对数据的操作)。
通过这一点,我们已经准备好深入探讨汇编编程,并尝试真正编写可运行的程序,而不仅仅是在模板中输入几行代码并在调试器中观察寄存器的变化。
本书的下一部分,实用汇编部分,将以一章介绍并行数据处理开始。接着,你将学习宏的基础,并了解数据结构操作机制,我们还将看到我们的汇编代码如何与周围的操作系统交互,这非常重要。
第五章:并行数据处理
我记得坐在我的 ZX Spectrum 前,64KB 的内存(16KB ROM + 48KB RAM),老式磁带录音机插入其中,新的磁带也放了进去。在磁带上相对较多的程序中,有一个特别引起了我的注意。并不是因为它能做什么特别的事情;毕竟,它只是根据出生日期(事实上,我还必须输入当前日期)计算个人的生物节律图,并在屏幕上绘制出来。算法甚至没有任何复杂性(无论算法如何复杂,本质上只是对某些值进行正弦计算)。让我觉得有趣的是,屏幕上出现了一个“等待结果处理”的提示信息,信息框中出现了一种进度条,持续了将近半分钟(是的,我曾天真地认为在这个信息框“背后”可能真的有计算在进行),同时三个图表似乎同时在绘制。嗯,看起来好像它们是在同时绘制。
该程序是用 BASIC 编写的,因此逆向它是一项相对简单的任务。简单,但令人失望。当然,绘制图表时并没有并行处理,仅仅是同一个函数按顺序在每个点上为每个图表依次调用。
显然,ZX Spectrum 并不是一个适合寻找并行处理能力的平台。而英特尔架构则提供了这样一种机制。在本章中,我们将探讨Streaming SIMD Extension(SSE)提供的几个功能,它允许对所谓的打包整数、打包的单精度或双精度浮点数进行同时计算,这些数据被包含在 128 位寄存器中。
本章将简要介绍 SSE 技术,回顾其可用的寄存器及访问模式。随后,我们将继续讨论算法本身的实现,该算法涉及与所有三种生物节律相关的单精度浮点值的并行操作。
一些对生物节律图计算至关重要的步骤,在高级语言中实现时非常简单,比如正弦计算、指数运算和阶乘,在这里将详细介绍,因为我们暂时没有访问任何数学库的权限;因此,我们没有现成的实现这些计算所涉及的过程。我们将为每个步骤实现自己的解决方案。
SSE
英特尔 Pentium II 处理器引入了MMX技术(非官方称为多媒体扩展,然而这种别名从未在英特尔文档中使用),它使我们能够使用 64 位寄存器处理打包的整数数据。尽管这种技术带来了显著的好处,但至少存在两个缺点:
-
我们只能处理整数数据
-
MMX 寄存器被映射到浮点单元(FPU)的寄存器上
尽管比没有更好,MMX 技术仍然没有提供足够的计算能力。
随着 Pentium III 处理器的推出,情况发生了很大变化,它引入了自己的 128 位寄存器和指令集,允许在标量或打包字节、32 位整数、32 位单精度浮点值或 64 位双精度浮点值上执行广泛的操作,且支持流式 SIMD 扩展。
寄存器
基于 Intel 的处理器有 8 个 XMM 寄存器可供 SSE 使用,在 32 位平台上,这些寄存器命名为 XMM0 到 XMM7,而在 64 位平台上,命名为 XMM0 到 XMM15。需要注意的是,在 64 位平台上,只有 8 个 XMM 寄存器可用,且只有在非长模式下。
每个 XMM 寄存器的内容可以被视为以下类型之一:
-
16 字节(我们在 AES-NI 实现中看到的)
-
八个 16 位字
-
四个 32 位双字
-
四个 32 位单精度浮点数(我们将在本章中以这种方式使用寄存器)
-
两个 64 位四字
-
两个 64 位双精度浮点数
SSE 指令能够对寄存器的相同部分作为操作数进行操作,也可以对操作数的不同部分进行操作(例如,它们可以将源寄存器的低位部分移动到目标寄存器的高位部分)。
版本更新
目前,SSE 指令集(以及该技术)有五个版本,分别如下:
-
SSE:这一技术于 1999 年推出,包含了该技术及其指令的初步设计
-
SSE2:此版本随 Pentium 4 发布,带来了 144 条新指令
-
SSE3:虽然 SSE3 仅增加了 13 条新指令,但它引入了执行所谓“水平”操作的能力(在单个寄存器上执行的操作)
-
SSSE3:这一版本引入了 16 条新指令,其中包括用于水平整数操作的指令
-
SSE4:这一版本带来了另外 54 条指令,从而极大地方便了开发人员
生物节律计算器
我之前提到过,我想重申的是,在我看来,理解和学习事物的最好方式是通过示例。我们通过提到一个旧的生物节律水平计算程序开始了这一章,似乎当这个程序使用 SSE 架构实现时,它可能是一个简单而又很好的例子,展示了如何执行并行计算。下一节中的代码展示了 2017 年 5 月 9 日到 2017 年 5 月 29 日之间,针对我个人的生物节律计算,将结果存储到一个表格中。所有的计算(包括指数运算和正弦运算)都是使用 SSE 指令实现的,显然也使用了 XMM 寄存器。
这个想法
“生物节律”一词源于两个希腊词;“bios”意为生命,“rhythmos”意为节奏。这个概念最早由德国耳鼻喉科医生威廉·弗里斯提出,他生活在十九世纪末至二十世纪初。他认为我们的生活受到生物周期的影响,这些周期影响着我们的心理、身体和情感方面。
弗里斯推导出了三个主要的生物节律周期:
-
身体周期
持续时间:23 天
表示:
-
协调性
-
力量
-
健康状况
-
-
情感周期
持续时间:28 天
表示:
-
创造力
-
敏感性
-
心情
-
觉察力
-
-
智力周期
持续时间:33 天
表示:
-
警觉性
-
分析和逻辑能力
-
通信
-
这个理论本身可能相当有争议,特别是因为大多数科学界认为它是伪科学;然而,它足够科学,至少可以作为并行数据处理机制的一个示例。
算法
生物节律计算的算法相当简单,可以说是微不足道的。
用于指定特定日期下每个生物节律的变化率的变量值在(-1.0, 1.0)范围内,并使用以下公式计算:
x = sin((2 * PI * t) / T)
在这里,t表示从某人出生日期到我们希望了解其生物节律值的日期(很可能是当前日期)所经过的天数,T是给定生物节律的周期。
借助 SSE 技术,我们能优化的东西并不多。我们可以做的确实是一次性计算所有三种生物节律的数据,这足以展示 Streaming SIMD Extension 的能力和威力。
数据部分
由于源文件中各部分没有特定的顺序,我们将从数据部分开始简要查看,以更好地理解代码。数据部分,或者更准确地说,数据在数据部分的排列,是相当自明的。重点放在数据对齐上,允许通过对齐的 SSE 指令更快地访问:
section '.data' data readable writeable
*; Current date and birth date*
*; The dates are arranged in a way most suitable*
*; for use with XMM registers*
cday dd 9 *; Current day of the month*
cyear dd 2017 *; Current year*
bday dd 16 *; Birth date day of the month*
byear dd 1979 *; Birth year*
cmonth dd 5 *; 1-based number of current month*
dd 0
bmonth dd 1 *; 1-based number of birth month*
dd 0
*; These values are used for calculation of days*
*; in both current and birth dates*
dpy dd 1.0
dd 365.25
*; This table specifies number of days since the new year*
*; till the first day of specified month.*
*; Table's indices are zero based*
monthtab:
dd 0 *; January*
dd 31 *; February*
dd 59 *; March*
dd 90 *; April*
dd 120 *; May*
dd 151 *; June*
dd 181 *; July*
dd 212 *; August*
dd 243 *; September*
dd 273 *; October*
dd 304 *; November*
dd 334 *; December*
align 16
*; Biorhythmic periods*
T dd 23.0 *; Physical*
dd 28.0 *; Emotional*
dd 33.0 *; Intellectual*
pi_2 dd 6.28318 *; 2xPI - used in formula*
align 16
*; Result storage*
*; Arranged as table:*
*; Physical : Emotional : Intellectual : padding*
output rd 20 * 4
*; '.idata' section: contains import information,*
*; is readable, is writeable*
section '.idata' import data readable writeable
*; 'library' macro from 'win32a.inc' creates*
*; proper entry for importing*
*; functions from a dynamic link library.*
*; For now it is only 'kernel32.dll'.*
library kernel, 'kernel32.dll'
*; 'import' macro creates the actual entries*
*; for functions we want to import from a dynamic link library*
import kernel,\
exitProcess, 'ExitProcess'
代码
我们将从 32 位 Windows 的标准模板开始(如果你使用的是 Linux,可以安全地使用 Linux 模板)。
标准头文件
首先,我们告诉汇编器我们期望的输出类型,即 GUI 可执行文件(尽管它没有任何 GUI),我们的入口点是什么,当然,我们还包括win32a.inc文件,以便能够调用ExitProcess()Windows API。然后,我们创建代码部分:
format PE GUI *; Specify output file format*
entry _start *; Specify entry point*
include 'win32a.inc' *; Include some macros*
section '.text' code readable executable *; Start code section*
main()函数
以下是 C/C++中main()函数的类比,它控制着整个算法,并负责执行所有必要的准备工作以及预报计算循环的执行。
数据准备步骤
首先,我们需要对日期进行一些小的修正(月份以其数字表示)。我们关注的是从 1 月 1 日到某个月第一天的天数。进行此修正的最简单和最快方法是使用一个包含 12 个条目的小表格,表格中包含了 1 月 1 日到每个月第一天的天数。这个表格叫做 monthtab,并且位于数据段中。
*;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;*
*;*
*; Entry point*
*;*
*;-----------------------------------------------------*
_start:
mov ecx, 20 *; Length of biorhythm data to*
*; produce*
mov eax, [bmonth] *; Load birth month*
dec eax *; Decrement it in order to address*
*; 0-based array*
mov eax, [monthtab + eax * 4] *; Replace month with number of days*
*; since New Year*
mov [bmonth], eax *; Store it back*
mov eax, [cmonth] *; Do the same for current month*
dec eax
mov eax, [monthtab + eax * 4]
mov [cmonth], eax
xor eax, eax *; Reset EAX as we will use it as counter*
上述代码展示了此修复的应用:
-
我们从出生日期中读取月份数字
-
由于我们使用的表格实际上是一个 0 基数组,因此需要将其递减
-
用从表格中读取的值替换原始的月份数字
顺便提一下,读取表格值时使用的寻址模式是比例/索引/基址/位移的变体。正如我们所看到的,monthtab 是位移,eax 寄存器存储索引,4 是比例因子。
这两个日期的日/月/年特别安排以便正确地适应 XMM 寄存器并简化计算。看起来,以下代码的第一行是将 cday 的值加载到 XMM0 中,但实际上,所用的指令是从 cday 的地址开始加载 xmmword(128 位数据类型),意味着它将四个值加载到 XMM0 中:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 |
|---|---|---|---|
byear | bday | cyear | cday |
| 1979 | 16 | 2017 | 9 |
XMM0 寄存器中的数据表示
类似地,第二条 movaps 指令加载了 XMM1 寄存器,从 cmonth 的地址开始加载四个双字:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 |
|---|---|---|---|
| 0 | bmonth | 0 | cmonth |
| 0 | 0 | 0 | 120 |
XMM1 寄存器中的数据表示
如我们所见,当将两个表格直接放置在彼此之上并将其视为 XMM 寄存器 0 和 1 时,我们在 XMM0 和 XMM1 中加载了 cmonth/cday 和 bmonth/bday,它们共享相同的双字。我们将在稍后看到这种数据安排为何如此重要。
movaps 指令只能在两个 XMM 寄存器之间,或者一个 XMM 寄存器和一个 16 字节对齐的内存位置之间移动数据。若要访问未对齐的内存位置,应使用 movups。
在以下代码片段的最后两行中,我们将刚刚加载的双字值转换为单精度浮点数:
movaps xmm0, xword[cday] *; Load the day/year parts of both dates*
movapd xmm1, xword[cmonth] *; Load number of days since Jan 1st for both dates*
cvtdq2ps xmm0, xmm0 *; Convert loaded values to single precision floats*
cvtdq2ps xmm1, xmm1
我们仍然没有完成将日期转换为天数的操作,因为年份依然是年份,并且每个月的天数和两个日期从 1 月 1 日开始的天数仍然分别存储。我们在对每个日期的天数进行求和之前,只需要将每一年乘以 365.25(其中 0.25 是对闰年的补偿)。然而,XMM 寄存器的部分内容无法像通用寄存器的部分内容那样被单独访问(例如,在 EAX 中没有类似 AX、AH、AL 的部分)。不过,我们可以通过使用特殊指令来操作 XMM 寄存器的部分内容。在以下代码片段的第一行,我们将 XMM2 寄存器的低 64 位部分加载到存储在 dpy(每年天数)位置的两个浮动值中。这些值是 1.0 和 365.25。你可能会问,1.0 与此有何关系,答案可以在下表中看到:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| 1979.0 | 16.0 | 2017.0 | 9.0 | XMM0 |
| 0.0 | 0.0 | 0.0 | 120.0 | XMM1 |
| 0.0 | 0.0 | 365.25 | 1.0 | XMM2 |
XMM0 - XMM2 寄存器的内容
对 XMM 寄存器的打包操作(打包意味着对多个值进行操作)大多数时候是按列进行的。因此,为了将 2017.0 乘以 365.25,我们需要将 XMM2 与 XMM0 相乘。然而,我们也不能忘记 1979.0,最简单的方式是使用 movlhps 指令将 XMM2 寄存器的低部分内容复制到其高部分。
movq xmm2, qword[dpy] *; Load days per year into lower half of XMM2*
movlhps xmm2, xmm2 *; Duplicate it to the upper half*
在这些指令执行后,XMM0 - XMM2 寄存器的内容应该如下所示:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| 1979.0 | 16.0 | 2017.0 | 9.0 | XMM0 |
| 0.0 | 0.0 | 0.0 | 120.0 | XMM1 |
| 365.25 | 1.0 | 365.25 | 1.0 | XMM2 |
执行 movlhps 后,XMM0 - XMM2 寄存器的内容
使用 pinsrb/pinsrd/pinsrq 指令在需要时将单个字节/双字/四字插入 XMM 寄存器中。为了演示水平操作,这些指令在我们的代码中并未使用。
现在我们可以安全地进行乘法和加法运算:
addps xmm1, xmm0 *; Summation of day of the month with days since January 1st*
mulps xmm2, xmm1 *; Multiplication of years by days per year*
haddps xmm2, xmm2 *; Final summation of days for both dates*
hsubps xmm2, xmm2 *; Subtraction of birth date from current date*
上述代码首先计算从 1 月 1 日到两日期月日的总天数。在第二行,最后,它将两个日期的年份乘以每年的天数。这一行也解释了为什么每年天数的值后面伴随着 1.0——因为我们在将 XMM1 与 XMM2 相乘时,不希望丢失之前计算的天数,我们只需将从 1 月 1 日以来的天数乘以 1.0。
此时,三个 XMM 寄存器的内容应该如下所示:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| 1979.0 | 16.0 | 2017.0 | 9.0 | XMM0 |
| 1979.0 | 16.0 | 2017.0 | 129.0 | XMM1 |
| 722829.75 | 16.0 | 736709.25 | 129.0 | XMM2 |
XMM0 - XMM2 寄存器在加上天数并乘以每年天数后,XMM2 和 XMM1 寄存器相对部分的内容
还有两个操作需要执行:
-
完成每个日期的总天数计算
-
从较早的日期中减去较晚的日期
到这时,我们需要用于计算的所有值都已存储在单个寄存器 XMM2 中。幸运的是,SSE3 引入了两条重要指令:
-
haddps:单精度值的水平加法将目标操作数的前两个双字和后两个双字中的单精度浮点值相加,并将结果分别存储到目标操作数的前两个双字中。第三个和第四个双字也会被覆盖,第三个双字的值与第一个双字相同,第四个双字的值与第二个双字相同。
-
hsubps:单精度值的水平减法从目标操作数的第二个双字中减去单精度浮点值,再从目标操作数的第三个双字中减去目标操作数第四个双字的值,并将结果分别存储到目标操作数的前两个双字和后两个双字中。
完成hsubps指令后,寄存器的内容应为:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| 1979.0 | 16.0 | 2017.0 | 9.0 | XMM0 |
| 1979.0 | 16.0 | 2017.0 | 129.0 | XMM1 |
| 13992.5 | 13992.5 | 13992.5 | 13992.5 | XMM2 |
XMM0 - XMM2 寄存器在加法和随后减法操作后的内容
如我们所见,XMM2 寄存器包含两个日期之间的天数(出生日期和当前日期)减去 1,因为出生当天不包括在内(此问题将在计算循环中解决);
movd xmm3, [dpy] *; Load 1.0 into the lower double word of XMM3*
movlhps xmm3, xmm3 *; Duplicate it to the third double word of XMM3*
movsldup xmm3, xmm3 *; Duplicate it to the second and fourth double words of XMM3*
前三行通过加载存储在dpy中的双字(值为1.0)来设置我们预测的步长,并将此值传播到整个 XMM3 寄存器中。我们将在每个新的预测日期中将 XMM3 加到 XMM2。
接下来的三行与前面三行在逻辑上类似;它们将 XMM4 寄存器的四个单精度浮点值设置为2PI*:
movd xmm4, [pi_2]
movlhps xmm4, xmm4
movsldup xmm4, xmm4
进入计算循环之前的最后一步:我们将 XMM1 加载上生物节律周期的长度,并将eax寄存器设置为指向我们将存储输出数据(预测结果)的位置。根据数据段中数据的排列,XMM1 寄存器的第四个单精度值将被加载为2PI*,但是,由于第四个单精度值在我们的计算中不被使用,我们将其保持原样。当然,我们也可以使用pinsrd xmm1, eax, 3指令将其清零:
movaps xmm1, xword[T]
lea eax, [output]
现在,我们已经设置好了数据,并准备好计算给定日期范围内的生物节律值。寄存器 XMM0 到 XMM4 现在应该具有以下值:
| 96 - 127 位 | 64 - 95 位 | 32 - 63 位 | 0 - 31 位 | 寄存器名称 |
|---|---|---|---|---|
| 1979.0 | 16.0 | 2017.0 | 9.0 | XMM0 |
| 6.2831802 | 33.0 | 28.0 | 23.0 | XMM1 |
| 13992.5 | 13992.5 | 13992.5 | 13992.5 | XMM2 |
| 1.0 | 1.0 | 1.0 | 1.0 | XMM3 |
| 6.2831802 | 6.2831802 | 6.2831802 | 6.2831802 | XMM4 |
计算循环
一旦所有准备工作完成,我们生成预测的计算循环相当简单。首先,我们增加天数值,这具有双重作用——在第一次迭代中,解决了不包括出生日的问题,并在剩余迭代中将当前日期向前推一天。
第二条指令将 XMM4 寄存器复制到 XMM0,这将用于大部分计算,并将其与 XMM2 中的天数乘以第三条指令执行——实际上计算了公式中的(2PIt)部分。
第四条指令通过将 XMM0 除以生物节律周期长度来完成我们需要计算正弦值的值的计算:
.calc_loop:
addps xmm2, xmm3 *; Increment the number of days by 1.0*
movaps xmm0, xmm4 *; Set XMM0 to contain 2*PI values*
mulps xmm0, xmm2 *; Actually do the 2*PI*t*
divps xmm0, xmm1 *; And complete by (2*PI*t)/T*
现在我们需要计算这些值的正弦值,这有点棘手,因为我们将使用正弦计算的算法和相对较大的数值。解决方案很简单——我们需要将这些值归一化,使其适合(0.0, 2PI*)范围。这由adjust()过程实现:
call adjust *; Adjust values for sine computations*
调整了 XMM0 中的值(忽略 XMM0 的第四部分值,因为它不相关),我们现在可以为寄存器的前三个单精度浮点部分计算正弦:
call sin_taylor_series *; Compute sine for each value*
我们将计算得到的正弦值存储到由eax寄存器指向的表中(由于该表在 16 字节边界上对齐,我们可以安全地使用movaps指令,比其movups对应指令稍快)。然后,我们将表指针前进 16 字节,递减 ECX,并在 ECX 不为 0 时继续循环,使用loop指令。
当 ECX 达到0时,我们简单地终止进程:
movaps [eax], xmm0 *; Store the result of current iteration*
add eax, 16
loop .calc_loop
push 0
call [exitProcess]
表在循环结束时应包含以下值:
| 日期 | 身体(P) | 情感(S) | 智力(I) | 无关 |
|---|---|---|---|---|
| 2017 年 5 月 9 日 | 0.5195959 | -0.9936507 | 0.2817759 | -NAN |
| 2017 年 5 月 10 日 | 0.2695642 | -0.9436772 | 0.4582935 | -NAN |
| 2017 年 5 月 11 日 | -8.68E-06 | -0.8462944 | 0.6182419 | -NAN |
| 2017 年 5 月 12 日 | -0.2698165 | -0.7062123 | 0.7558383 | -NAN |
| 2017 年 5 月 13 日 | -0.5194022 | -0.5301577 | 0.8659862 | -NAN |
| 2017 年 5 月 14 日 | -0.7308638 | -0.3262038 | 0.9450649 | -NAN |
| 2017 年 5 月 15 日 | -0.8879041 | -0.1039734 | 0.9898189 | -NAN |
| 2017 年 5 月 16 日 | -0.9790764 | 0.1120688 | 0.9988668 | -NAN |
| 2017 年 5 月 17 日 | -0.9976171 | 0.3301153 | 0.9718016 | -NAN |
| 2017 年 5 月 18 日 | -0.9420508 | 0.5320629 | 0.909602 | -NAN |
| 2017 年 5 月 19 日 | -0.8164254 | 0.7071083 | 0.8145165 | -NAN |
| 2017 年 5 月 20 日 | -0.6299361 | 0.8467072 | 0.6899831 | -NAN |
| 2017 年 5 月 21 日 | -0.3954292 | 0.9438615 | 0.5407095 | -NAN |
| 2017 年 5 月 22 日 | -0.128768 | 0.9937283 | 0.3714834 | -NAN |
| 2017 年 5 月 23 日 | 0.1362932 | 0.9936999 | 0.1892722 | -NAN |
| 2017 年 5 月 24 日 | 0.3983048 | 0.9438586 | -8.68E-06 | -NAN |
| 2017 年 5 月 25 日 | 0.6310154 | 0.8467024 | -0.18929 | -NAN |
| 2017 年 5 月 26 日 | 0.8170633 | 0.7069295 | -0.371727 | -NAN |
| 2017 年 5 月 27 日 | 0.9422372 | 0.5320554 | -0.5407244 | -NAN |
| 2017 年 5 月 28 日 | 0.9976647 | 0.3303373 | -0.6901718 | -NAN |
正弦输入值的调整
如我们所见,使用 SSE 指令非常方便和有效;尽管我们大多是在从内存加载数据到寄存器并在寄存器内移动数据,但我们还没有看到它的实际效果。计算循环中有两个过程执行实际的计算,其中一个是 adjust() 过程。
由于算法整体非常简单,而且由于两个过程仅从一个地方调用,因此我们没有遵循任何特定的调用约定;相反,我们使用 XMM0 寄存器传递浮点值,使用 ECX 寄存器传递整数参数。
对于 adjust() 过程,我们只有一个参数,它已经加载到 XMM0 寄存器中,因此我们只需调用该过程:
*;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;*
*;*
*; Value adjustment before calculation of SIN()*
*; Parameter is in XMM0 register*
*; Return value is in XMM0 register*
*;-----------------------------------------------------*
adjust:
push ebp
mov ebp, esp
sub esp, 16 * 2 *; Create the stack frame for local variables*
这是为局部变量和临时存储程序中使用的非通用寄存器创建堆栈帧的标准方式,方法是将堆栈指针 ESP/RSP 保存到 EBP/RBP 寄存器中(我们可以自由使用其他通用寄存器)。通用寄存器可以通过在分配局部变量空间后立即发出 push 指令保存到堆栈中。局部变量空间的分配通过从 ESP/RSP 寄存器中减去变量的总大小来实现。
分配空间的寻址方式在以下代码中显示:
movups [ebp - 16], xmm1 *; Store XMM1 and XMM2 registers*
movups [ebp - 16 * 2], xmm2
在前两行中,我们临时存储了 XMM1 和 XMM2 寄存器的内容,因为我们将要使用它们,但需要保留它们的值。
输入值的调整非常简单,可以通过以下 C 语言代码表示:
return v - 2*PI*floorf(v/(2*PI));
然而,在 C 语言中,我们必须对每个值调用此函数(除非使用内建函数),而在汇编语言中,我们可以通过一些简单的 SSE 指令同时调整这三个值:
movd xmm1, [pi_2] *; Load singles of the XMM1 register with 2*PI*
movlhps xmm1, xmm1
movsldup xmm1, xmm1
我们已经熟悉了上述的顺序,它将一个双字加载到 XMM 寄存器并复制到其中的每个单精度浮点部分。这里,我们将 2PI* 加载到 XMM1。
以下算法执行实际的计算:
-
我们将输入参数复制到 XMM2 寄存器中
-
将它的单精度浮点数除以 2PI*
-
向下舍入结果(SSE 没有地板或天花板指令,取而代之的是我们可以使用
roundps并在第三个操作数中指定舍入模式;在我们的案例中,我们指示处理器粗略地向下舍入) -
将向下舍入的结果乘以2PI*
-
从初始值中减去它们,得到适合于(0.0, 2PI*)范围的结果
其汇编实现如下:
movaps xmm2, xmm0 *; Move the input parameter to XMM2*
divps xmm2, xmm1 *; Divide its singles by 2*PI*
roundps xmm2, xmm2, 1b *; Floor the results*
mulps xmm2, xmm1 *; Multiply floored results by 2*PI*
subps xmm0, xmm2 *; Subtract resulting values from the*
*; input parameter*
movups xmm2, [ebp - 16 * 2] *; Restore the XMM2 and XMM1 registers*
movups xmm1, [ebp - 16]
mov esp, ebp *; "Destroy" the stack frame and return*
pop ebp
ret
最后一次操作的结果已经在 XMM0 中,因此我们只需从过程返回到计算循环。
计算正弦
我们很少会考虑如何计算正弦或余弦,而不实际拥有一个已知直角三角形的两条直角边和斜边长度。至少有两种方法可以快速高效地进行这些计算:
-
CORDIC 算法:这代表坐标旋转数字计算机。它在简单计算器或原始硬件设备中实现。
-
泰勒级数:一种快速的近似算法。它不提供准确值,但足以满足我们的需求。
另一方面,LIBC 使用不同的算法,我们可以在这里实现,但这将远远超过一个简单的示例。因此,我们在代码中使用的是最简单的近似算法的简单实现,它为我们提供了相当不错的精度(比本程序需要的精度更高),精度可达到小数点后六位——这是用于三角函数的泰勒级数(也称为麦克劳林级数)。
使用泰勒级数计算正弦的公式如下:
sin(x) = x - x³/3! + x⁵/5! - x⁷/7! + x⁹/9! ...
这里,省略号表示一个无限函数。然而,我们不需要无限运行它来获得令人满意的精度(毕竟,我们只关心小数点后两位),相反,我们将运行它 8 次迭代。
就像adjust()过程一样,我们不会遵循任何特定的调用约定,并且由于我们需要计算正弦的参数已经在 XMM0 中,因此我们将其保留在那里。sin_taylor_series过程的头部对我们来说没有任何新内容:
*;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;*
*;*
*; Calculation of SIN() using the Taylor Series*
*; approximation:*
*; sin(x) = x - x³/3! + x⁵/5! - x⁷/7! + x⁹/9! ...*
*; Values to calculate the SIN() of are in XMM0 register*
*; Return values are in XMM0 register*
*;-----------------------------------------------------*
sin_taylor_series:
push ebp *; Create stack frame for 5 XMM registers*
mov ebp, esp
sub esp, 5 * 16
push eax ecx *; Temporarily store EAX and ECX*
xor eax, eax *; and set them to 0*
xor ecx, ecx
movups [ebp - 16], xmm1 *; Temporarily store XMM1 to XMM5 on stack or, to be more*
movups [ebp - 16 * 2], xmm2 *; precise, in local variables.*
movups [ebp - 16 * 3], xmm3
movups [ebp - 16 * 4], xmm4
movups [ebp - 16 * 5], xmm5
movaps xmm1, xmm0 *; Copy the parameter to XMM1 and XMM2*
movaps xmm2, xmm0
mov ecx, 3 *; Set ECX to the first exponent*
以下计算循环很简单,且不包含我们尚未见过的指令。然而,有两个过程调用,每个调用有两个参数。参数通过 XMM0 寄存器传递(三个单精度浮点数),ECX 寄存器包含当前使用的指数值:
.l1:
movaps xmm0, xmm2 *; Exponentiate the initial parameter*
call pow
movaps xmm3, xmm0
call fact *; Calculate the factorial of current exponent*
movaps xmm4, xmm0
divps xmm3, xmm4 *; Divide the exponentiated parameter by the factorial of the exponent*
test eax, 1 *; Check iteration for being odd number, add the result to accumulator*
*; subtract otherwise*
jnz .plus
subps xmm1, xmm3
jmp @f
.plus:
addps xmm1, xmm3
@@: *; Increment current exponent by 2*
add ecx, 2
inc eax
cmp eax, 8 *; and continue till EAX is 8*
jb .l1
movaps xmm0, xmm1 *; Store results into XMM0*
所有计算已完成,现在我们得到了三个输入的正弦值。对于第一次迭代,XMM0 中的输入如下:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| (无关紧要) | 0.28564453 | 4.8244629 | 2.5952148 | XMM0 |
此外,我们的sin()近似值通过泰勒级数八次迭代后的结果如下:
| 位 96 - 127 | 位 64 - 95 | 位 32 - 63 | 位 0 - 31 | 寄存器名称 |
|---|---|---|---|---|
| (无关) | 0.28177592 | -0.99365967 | 0.51959586 | XMM0 |
这展示了一个完美的(至少对于我们的需求来说)近似级别。然后,我们恢复之前保存的 XMM 寄存器并返回到调用程序:
movups xmm1, [ebp - 16]
movups xmm2, [ebp - 16 * 2]
movups xmm3, [ebp - 16 * 3]
movups xmm4, [ebp - 16 * 4]
movups xmm5, [ebp - 16 * 5]
pop ecx eax
mov esp, ebp
pop ebp
ret
指数运算
我们在sin_taylor_series过程中使用了指数运算,这个算法在处理实数作为指数时并不像看起来那么简单;然而,我们很幸运,因为泰勒级数仅使用自然数来进行这类运算。但值得一提的是,如果我们需要更大的指数,算法将会变得非常缓慢。因此,我们的指数运算算法实现尽可能简单——我们仅仅将参数 XMM0 自乘 ECX-1 次。ECX 会减少 1 次,因为不需要计算x¹:
*;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;*
*;*
*; Trivial exponentiation function*
*; Parameters are:*
*; Values to exponentiate in XMM0*
*; Exponent is in ECX*
*; Return values are in XMM0*
*;-----------------------------------------------------*
pow:
push ebp
mov ebp, esp
sub esp, 16
push ecx
dec ecx *; The inputs are already x1 so we decrement the exponent*
movups [ebp - 16], xmm1
movaps xmm1, xmm0 *; We will be mutliplying XMM0 by XMM1*
.l1:
mulps xmm0, xmm1
loop .l1
movups xmm1, [ebp - 16]
pop ecx
mov esp, ebp
pop ebp
ret
阶乘
我们还使用了阶乘,因为我们将指数值除以其阶乘。给定数字n的阶乘是所有小于或等于n的正整数的积:
*;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;*
*;*
*; Simple calculation of factorial*
*; Parameter is in ECX (number to calculate the factorial of)*
*; Return value is in XMM0 register*
*;-----------------------------------------------------*
fact:
push ebp
mov ebp, esp
sub esp, 16 * 3
push ecx
movups [ebp - 16], xmm1
movups [ebp - 16 * 2], xmm2
mov dword[ebp - 16 * 3], 1.0
movd xmm2, [ebp - 16 * 3]
movlhps xmm2, xmm2
movsldup xmm2, xmm2
movaps xmm0, xmm2
movaps xmm1, xmm2
.l1:
mulps xmm0, xmm1
addps xmm1, xmm2
loop .l1
movups xmm2, [ebp - 16 * 2]
movups xmm1, [ebp - 16]
pop ecx
mov esp, ebp
pop ebp
ret
AVX-512
本章如果没有提到 AVX-512(高级矢量扩展 512 位)将不完整。事实上,它由多个扩展组成,而其中只有核心扩展——AVX-512F("F"代表基础)是所有处理器的必需部分。AVX-512 不仅增加了新的指令,还极大增强了并行(矢量化)计算的实现,使得可以对最长达 512 位的单精度或双精度浮点值的向量进行计算。此外,增加了 32 个新的 512 位寄存器(ZMM0 - ZMM31),其三元逻辑使其类似于专用平台。
总结
本章中的示例代码旨在展示现代基于 Intel 的处理器的并行数据处理能力。当然,所使用的技术远不能提供如 CUDA 等架构的强大功能,但它确实能够显著加速某些算法。尽管我们这里工作的算法非常简单,几乎不需要任何优化,因为它仅使用 FPU 指令就能实现,我们几乎看不出任何区别,但它仍然展示了如何同时处理多个数据。一个更好的应用场景可能是解决n体问题,因为 SSE 允许在三维空间内同时计算所有向量,甚至可以实现多层感知器(人工神经网络的一种类型),这使得能够一次性处理多个神经元;如果网络足够小,还可以将它们都存放在可用的 XMM 寄存器中,无需从/向内存移动数据。特别需要注意的是,有时看似复杂的过程,当使用 SSE 实现时,可能仍然比单条 FPU 指令更快。
现在我们至少了解了一项可能让我们生活更轻松的技术,我们将学习汇编器如何通过宏指令,尽管不能简化工作,但肯定能减轻汇编开发者的工作负担。类似于 C 语言或其他支持类似功能的编程语言中的宏,宏指令能够带来显著的积极影响,允许通过一个宏指令替换一系列指令,反复或有条件地汇编或跳过某些指令序列,甚至在汇编器不支持我们所需指令时,创建新的指令(虽然我还没有遇到过这种情况,但“永远不要说永远”)。