精通汇编语言编程-全-
精通汇编语言编程(全)
原文:
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 语言或其他支持类似功能的编程语言中的宏,宏指令能够带来显著的积极影响,允许通过一个宏指令替换一系列指令,反复或有条件地汇编或跳过某些指令序列,甚至在汇编器不支持我们所需指令时,创建新的指令(虽然我还没有遇到过这种情况,但“永远不要说永远”)。
第六章:宏指令
使用汇编语言实现你的思想很有趣(我肯定已经说过这个了,而且可能还不止一次)。然而,当涉及到某些操作时,它可能变得相当烦人,因为这些操作必须在程序的不同部分重新实现。一种可能的解决方法是将这些操作实现为一个过程,并在需要时调用它。然而,一旦你有了一个过程,并且它接收超过零个参数,这个方法也可能很快变得令人烦恼。在高级语言中,你只需"传递"参数给一个函数,而在汇编中,你必须根据所选择的调用约定实际将它们传递给一个过程,这反过来可能带来更多的麻烦,尤其是在寄存器管理(如果参数通过特定寄存器传递)或访问栈的过程中。有时候,这种复杂性是值得的,但并非总是如此,尤其是当涉及到一组简短的重复指令时。这正是宏指令可以帮助我们避免许多麻烦和冗余工作的地方,更不用说调用时消耗的 CPU 时间(参数准备、过程的序言和尾声),这些微小的毫秒分数,最终可能会累计成相当可观的延迟。
本章我们将覆盖以下内容:
-
宏指令及其背后的机制
-
宏指令如何参数化
-
学习可变宏指令及其威力
-
了解常见的调用约定
-
审视额外的汇编指令和条件汇编
所有这些对于我们未来与本书的工作至关重要,因为我们将要探讨的方法和算法如果不使用这些方法,将会变得非常繁琐。
什么是宏指令?
首先,在我们深入宏指令的世界之前,我们必须了解它们到底是什么。简单来说,宏指令是指令序列的别名。你可能熟悉这个术语,它在高级语言中出现过(我们说"可能"是因为并不是所有高级语言都实现了这一特性),但我们还是会在这里解释一下。记得上一章中的以下序列吗?
movd xmm3, [dpy]
movlhps xmm3, xmm3
movsldup xmm3, xmm3
这个序列将一个 XMM 寄存器的所有四个单精度浮点数值(在这个特定情况下是 XMM3)从 dpy 指向的内存中加载。我们在代码中多次使用了这种序列,因此,将其替换为一个宏指令是非常自然的。这样,定义以下宏将使我们的代码看起来更加优雅和可读:
macro load_4 xmmreg, addr
{
movd xmmreg, [addr]
movlhps xmmreg, xmmreg
movsldup xmmreg, xmmreg
}
我们在代码中像这样使用它:
load_4 xmm3, dpy
load_4 xmm4, pi_2
这样会使代码看起来更优雅,也更具可读性。
括号是 FASM 的一个很棒的特性,而在 MASM 和 GAS 中则没有这个特性。相反,在 MASM 中,你将写出如下代码:
MACRO macro_name
; 宏体
ENDM
以及下面这段 GAS 代码:
.macro macro_name
; 宏体
`.endm`
它是如何工作的
宏指令的逻辑相当简单。预处理器解析代码中的宏指令定义,并将其存储,简而言之,就像一个字典,其中宏指令的名称是键,它的内容是值。当然,实际上更为复杂,因为宏指令可能有(而且大多数情况下都有)参数,更不用说它们可能还是可变的(即具有未定义数量的参数)。
当汇编器处理代码并遇到未知的指令时,它会检查这个字典,以查找具有相应名称的宏指令。一旦找到这样的条目,汇编器就会用其值替换宏指令——即扩展宏。考虑到汇编器看到如下内容:
load_4 xmm3, dpy
然后,它会参考收集到的宏指令定义,并将这一行替换为实际的代码:
movd xmm3, [dpy]
movlhps xmm3, xmm3
movsldup xmm3, xmm3
如果汇编器找不到相关的宏定义,错误报告机制会通知我们。
带参数的宏指令
虽然你完全可以定义一个不接收任何参数的宏指令,但你很少需要这样做。大多数情况下,你会定义需要至少一个参数的宏指令。以实现过程前言的宏指令为例:
macro prolog frameSize
{
push ebp
mov ebp, esp
sub esp, frameSize
}
上述宏指令中的 frameSize 属性是一个宏参数,在此示例中,用于指定栈帧的大小(以字节为单位)。使用此类宏指令的方法如下:
my_proc:
prolog 8
*; body of the procedure*
mov esp, ebp
pop ebp
ret
上述代码在逻辑上等价于(并且会被预处理器展开为)以下内容:
my_proc:
push ebp
mov ebp, esp
sub esp, 8
*; body of the procedure*
mov esp, ebp
pop ebp
ret
此外,我们还可以定义 return 宏,它实现栈帧的销毁并从过程返回:
macro return
{
mov ebp, esp
pop ebp
ret
}
这将使我们的过程更加简短:
my_proc:
prolog 8
*; body of the procedure*
return
这里,return 宏也是一个很好的无参数宏指令示例。
可变宏指令
在某些情况下,我们不知道同一个宏指令在不同地方被调用时会传递多少个参数,而 FASM 为这样的问题提供了一个非常好且简单的解决方案——支持可变宏指令。术语可变意味着一个操作符、过程或宏可以接受不同数量的操作数/参数。
从语法上看,可变宏指令非常简单。我们以宏关键字开始,然后是宏的名称,后跟一个逗号分隔的参数列表(如果有的话)。参数列表中的可变部分用方括号括起来。例如,如果我们有一个宏指令,它扩展为 printf() 函数或调用它,并且我们希望它具有类似的声明,那么宏声明会像这样开始:
macro printf fmt, [args]
这里,fmt 代表 printf() 函数的格式参数,args 表示所有可选的参数。
让我们考虑一个非常简单的prolog宏重构例子,除了堆栈帧的大小,它还接收一个寄存器列表,这些寄存器需要在过程体内被修改,因此需要保存在栈上:
macro prolog frameSize, [regs]
{
common
push ebp
mov ebp, esp
sub esp, frameSize
forward
push regs
}
在这里,你一定注意到了common和forward关键字,它们对于宏指令展开的正确性至关重要。变参宏指令的一个有趣特点是,它的内容会针对每一个变参(用方括号指定的参数)展开。由于在每次将寄存器(由regs参数指定)压入栈之后创建堆栈帧会显得很奇怪,因此我们必须指示预处理器只展开宏指令的特定部分一次,这正是common关键字的作用。
forward关键字(及其对应的reverse关键字)指示预处理器应该按照何种顺序处理变参。push regs这一行会展开为push指令,针对regs中指定的每个参数,前置的forward关键字指示预处理器按它们写入的顺序处理参数。例如,考虑以下代码:
my_proc:
prolog 8, ebx, ecx, edx
*; body of the procedure*
这段代码会展开为以下内容:
my_proc:
push ebp
mov ebp, esp
sub esp, 8
push ebx
push ecx
push edx
为了完整性,让我们对return宏指令进行适当的修复:
macro return [regs]
{
reverse
pop regs
common
mov esp, ebp
pop ebp
ret
}
在这里,为了举例,我们使用reverse关键字,因为我们指定了应该从栈中以完全相同的顺序恢复寄存器,这些寄存器在传递给prolog宏指令时的顺序。然后,过程会像这样:
my_proc:
prolog 8, ebx, ecx, edx
*; body of the function*
return ebx, ecx, edx
调用约定简介
在编写汇编语言代码时,调用过程时最好遵循一定的调用约定(参数传递给过程的方式),因为首先,这样可以最小化烦人的且难以查找的错误的发生,当然,也能帮助你将汇编模块与高级语言链接起来。对于 Intel 架构来说,有很多种调用约定,但我们只会考虑其中一些,稍后将在本书中使用。
我们已经了解了过程,并且在上一章中提到过“调用约定”这一术语,因此你可能会想,为什么现在才介绍这一机制。答案很简单——调用过程是一个需要某些准备的过程,而这些准备在每次过程调用时都应该是相同的,因此显然可以将这些准备以宏指令的形式实现。
首先,让我们看看本章中我们将涵盖的调用约定:

cdecl(32 位)
cdecl 调用约定是 C 和 C++高级语言中的标准约定。参数存储在栈上,最右边的参数首先压入栈中,最左边的参数最后压入栈中。恢复栈是调用者的责任,调用者在控制权返回时需要恢复栈。
模拟 cdecl 调用过程的最简单宏如下:
macro ccall procName, [args]
{
common
a = 0
if ~args eq
forward
a = a + 4
reverse
push args
end if
common
call procName
if a > 0
add esp, a
end if
}
这里的 if 语句是自解释的;不过,你可以暂时忽略它们,因为它们将在本章稍后部分进行讲解。
stdcall(32 位)
stdcall 调用约定几乎与 cdecl 相同,参数以相同的方式传递到栈中——最右边的参数最先被压入栈中,最左边的参数最后被压入栈中。唯一的区别是调用者无需处理栈的清理:
macro stdcall procName, [args]
{
if ~args eq
reverse
push args
end if
common
call procName
}
让我们考虑一个同时使用两种调用约定的简单示例:
cdecl_proc:
push ebp
mov ebp, esp
*; body of the procedure*
mov esp, ebp
pop ebp,
ret
stdcall_proc:
push ebp
mov ebp, esp
*; body of the procedure*
mov esp, ebp
pop ebp
ret 8 *; Increments the stack pointer by 8 bytes after*
*; return, thus releasing the space occupied*
*; by procedure parameters*
main:
ccall cdecl_proc, 128 *; 128 is a numeric parameter passed to*
*; the procedure*
stdcall stdcall_proc, 128, 32
虽然 cdecl_proc 和 stdcall_proc 过程都很清楚,但让我们更仔细地看一下 main 过程展开后的情况:
main:
push 128
call cdecl_proc
add esp, 4
*;*
push 32
push 128
call stdcall_proc
在前面的示例中,stdcall 宏调用还展示了当有多个参数时发生的情况——最右边的参数最先被压入栈中。这种机制使得在函数内部更容易、更直观地访问参数。鉴于栈帧的性质,我们可以这样访问它们:
mov eax, [ebp + 8] *; Would load EAX with 128*
mov eax, [ebp + 12] *; Would load EAX with 32*
我们使用 EBP 寄存器作为基指针。第一个(最左边的)参数位于 EBP 存储值偏移量8的位置,因为过程的返回地址和先前压入的 EBP 寄存器值正好占用了 8 个字节。下表展示了栈帧创建后的栈内容:
| 从 EBP 偏移量 | 内容 |
|---|---|
| +12 | 最右边的参数(32) |
| +8 | 最左边的参数(128) |
| +4 | 过程返回地址 |
| EBP 指向此处 | EBP 的上一个值 |
| -4 | 第一个栈帧变量 |
| .... | 其他栈帧变量 |
| .... | 保存的寄存器 |
| ESP 指向此处 | 当前栈位置 |
Microsoft x64(64 位)
Microsoft 在 64 位模式(长模式)下使用自己的调用约定,通过混合寄存器/栈的方式传递过程参数。这意味着只有前四个参数可以通过寄存器传递,其余的(如果有的话)应该压入栈中。以下表格展示了哪些寄存器被使用以及如何使用:
| 参数索引 (从零开始) | 整数/指针 | 浮点数 |
|---|---|---|
| 0 | RCX | XMM0 |
| 1 | RDX | XMM1 |
| 2 | R8 | XMM2 |
| 3 | R9 | XMM3 |
所有这些看起来很清楚,但我们需要特别注意两点:
-
栈必须在 16 字节边界上对齐
-
栈上需要 32 字节的影像空间——32 字节用于存放最后压入栈的参数(如果有的话)和返回地址之间的空间
以下宏指令(ms64_call)是简化版的实现,它是这一调用约定的原始实现。此特定宏不支持堆栈参数:
macro ms64_call procName, [args]
{
a = 0
if ~args eq
forward
if a = 0
push rcx
mov rcx, args
else if a = 1
push rdx
mov rdx, args
else if a = 2
push r8
mov r8, args
else if a = 3
push r9
mov r9, args
else
display "This macro only supports up to 4 parameters!",10,13
exit
end if
a = a + 1
end if
common
sub rsp, 32 *; Allocate shadow space*
call procName *; Call procedure*
add rsp, 32 *; Free shadow space*
forward
if ~args eq
if a = 4
pop r9
else if a = 3
pop r8
else if a = 2
pop rdx
else if a = 1
pop rcx
end if
a = a - 1
end if
}
考虑一个调用 64 位代码中标记为 my_proc 的过程的示例,使用 Microsoft x64 调用约定:
ms64_call my_proc, 128, 32
这样的宏指令将被扩展为以下内容:
push rcx *;Save RCX register on stack*
mov rcx, 128 *;Load it with the first parameter*
push rdx *;Save RDX register on stack*
mov rdx, 32 *;Load it with the second parameter*
sub rsp, 32 *;Create 32 bytes shadow space*
call my_proc *;Call the my_proc procedure*
add rsp, 32 *;Destroy shadow space*
pop rdx *;Restore RDX register*
pop rcx *;Restore RCX register*
AMD64(64 位)
默认情况下,64 位类 Unix 系统使用 AMD64 调用约定。它的理念非常相似,只是使用了不同的寄存器集合,并且没有阴影空间的要求。另一个区别是,AMD64 调用约定允许通过寄存器传递最多 6 个整数参数和最多 8 个浮点值:
| 参数索引 (从零开始) | 整数/指针 | 浮点数 |
|---|---|---|
| 0 | RDI | XMM0 |
| 1 | RSI | XMM1 |
| 2 | RDX | XMM2 |
| 3 | RCX | XMM3 |
| 4 | R8 | XMM4 |
| 5 | R9 | XMM5 |
| 6 | 在堆栈上 | XMM6 |
| 7 | 在堆栈上 | XMM7 |
以下宏指令是这种机制的原始实现。就像在微软 x64 示例中一样,这个实现也不处理堆栈参数:
macro amd64_call procName, [args]
{
a = 0
if ~args eq
forward
if a = 0
push rdi
mov rdi, args
else if a = 1
push rsi
mov rsi, args
else if a = 2
push rdx
mov rdx, args
else if a = 3
push rcx
mov rcx, args
else if a = 4
push r8
mov r8, args
else if a = 5
push r9
mov r9, args
else
display "This macro only supports up to 4 parameters", 10, 13
exit
end if
a = a + 1
end if
common
call procName
forward
if ~args eq
if a = 6
pop r9
else if a = 5
pop r8
else if a = 4
pop rcx
else if a = 3
pop rdx
else if a = 2
pop rsi
else if a = 1
pop rdi
end if
a = a - 1
end if
}
使用这样的宏,在面向类 Unix 系统的 64 位代码中调用过程 my_proc,例如:
amd64_call my_proc, 128, 32
将其扩展为:
push rdi *;Store RDI register on stack*
mov rdi, 128 *;Load it with the first parameter*
push rsi *;Store RSI register on stack*
mov rsi, 32 *;Load it with the second parameter*
call my_proc *;Call the my_proc procedure*
pop rsi *;Restore RSI register*
pop rdi *;Restore RDI register*
关于 Flat Assembler 宏功能的说明
Flat Assembler 相对于其他汇编器在英特尔平台上的一个巨大优势是其宏引擎。除了能够执行其原始任务——用宏指令的定义替换宏指令——它还能够执行相对复杂的计算,我敢称之为一种额外的编程语言。前面的示例仅仅展示了 FASM 宏处理器能力的极小一部分。虽然我们只用了 if 条件语句和一个变量,但在必要情况下,我们可以使用循环(while 或 repeat 语句)。例如,假设你有一串字符需要保持加密状态:
my_string db 'This string will be encrypted',0x0d, 0x0a, 0x00
my_string_len = $ - my_string
在这里,my_string_len 是字符串的长度。
$ 是一个特殊符号,表示当前地址。因此,$-my_string 表示当前地址减去 my_string 的地址,这就是字符串的长度。
可以通过一个简单的四行宏实现简化的 XOR 加密:
repeat my_string_len
load b byte from my_string + % - 1
store byte b xor 0x5a at my_string + % - 1
end repeat
这里的 % 符号表示当前的迭代,而 -1 的值是必需的,因为迭代计数从 1 开始。
这是 FASM 宏引擎能够执行的一个简短且原始的示例,实际上它的功能远不止此。然而,尽管本书主要使用 FASM 作为汇编语言,但它专注于英特尔汇编语言,而非特定方言,因此这些额外的信息超出了本书的范围。我强烈建议您参考FASM 文档。
MASM 和 GAS 中的宏指令
尽管宏指令机制背后的核心思想在所有汇编器中都是相同的,但宏指令的语法和引擎的功能有所不同。以下是 MASM 和 GAS 的两个简单宏示例。
Microsoft Macro Assembler
记得我们在第二章中的测试程序,*设置开发
环境*? 我们可以用以下宏指令替换调用show_message过程的代码:
MSHOW_MESSAGE MACRO title, message ;macro_name MACRO parameters
push message
push title
call show_message
ENDM
这可能使代码更具可读性,因为我们可以通过以下方式调用show_message过程:
MSHOW_MESSAGE offset ti, offset msg
GNU 汇编器
GNU 汇编器的宏引擎与微软 MASM 的宏引擎非常相似,但有一些语法差异(不考虑整体语法差异)是我们需要注意的。我们以第二章中的 Linux 测试程序中的output_message过程为例,*设置开发
环境*,并将printf()调用替换为一个简单的宏来演示。
.macro print message *; .macro macro_name parameter*
pushl \message *; Put the parameter on stack*
*; parameters are prefixed with '\'*
call printf *; Call printf() library function*
add $4, %esp *; Restore stack after cdecl function call*
.endm
output_message:
pushl %ebp
movl %esp, %ebp
print 8(%ebp) *; This line would expand to the above macro*
movl $0, %eax
leave
ret $4
其他汇编指令(FASM 特定)
到目前为止,我们大多认为宏指令是一种替代过程调用的方式,尽管我认为更准确的说法是它们是简化代码编写和维护的便捷工具。在本章的这一部分,我们将看到一些所谓的内置宏指令——汇编指令——它们大致可以分为三类:
-
条件汇编
-
重复指令
-
包含指令
根据汇编器的实现,可能还会有其他类别。你应该参考你正在使用的汇编器的文档,获取更多信息。
条件汇编
有时我们可能希望宏指令或代码片段根据特定条件进行不同的汇编。MASM 和 GAS 也提供了这一功能,但让我们回到 FASM(作为最方便的选择),考虑以下宏指令:
macro exordd p1, p2
{
if ~p1 in <eax, ebx, ecx, edx, esi, edi, ebp, esp> &\
~p2 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
push eax
mov eax, [p2]
xor [p1], eax
pop eax
else
if ~p1 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
xor [p1], p2
else if ~p2 in <eax, ebx, ecx, edx, esi, edi, ebp, esp>
xor p1, [p2]
else
xor p1, p2
end if
end if
}
起初看起来可能有点复杂,但宏的目的其实很简单。我们扩展了一个 XOR 指令,以便可以指定两个内存位置作为操作数,这是原始指令无法做到的。为了简化,我们只对双字值进行操作。
开始时,我们检查两个参数是否都是内存位置的标签,如果是,我们从其中一个加载值到寄存器,并执行 XOR 操作,就像第一个操作数是内存位置,第二个操作数是寄存器时一样。
如果此条件不为真,我们将进入宏指令的第二部分,根据第一个操作数是内存位置还是第二个操作数,或者它们是否都是通用寄存器,执行适当的 XOR 操作。
作为一个例子,假设我们有两个变量,分别为my_var1和my_var2,它们的值分别是0xCAFECAFE和0x02010201,并通过异或交换它们:
exordd my_var1, my_var2 *; a = a xor b*
mov ebx, [my_var2]
exordd ebx, my_var1 *; b = b xor a*
mov [my_var2], ebx
exordd my_var1, ebx *; a = a xor b*
exordd ebx, ebx *; Reset EBX register for extra fun*
一旦处理完成,上述代码将扩展为:
push eax *; exordd my_var1, my_var2*
mov eax, [my_var2]
xor [my_var1], eax
pop eax
mov ebx, [my_var2]
xor ebx, [my_var1] *; exordd ebx, my_var1*
mov [my_var2], ebx
xor [my_var1], ebx *; exordd [my_var1], ebx*
xor ebx, ebx *; exordd ebx, ebx*
如我们所见,exordd宏指令的展开方式取决于它的参数。
重复指令
有时可能需要重复相同的代码块,可能只会有些微的差异,甚至没有任何差异。汇编器有一些指令(有时称为内建宏指令),可以精确实现这一点。所有三种汇编器——FASM、MASM 和 GAS——都有三种常见的此类指令:
rept count:rept指令后跟count参数,简单地复制count次代码块中的内容。对于 Flat Assembler,我们可以声明第二个参数,它将等于当前迭代次数(从 1 开始)。例如,以下代码:
hex_chars:
rept 10 cnt {db '0' + cnt - 1}
rept 6 cnt {db 'A' + cnt - 1}
这将生成一个名为hex_chars的十六进制字符数组,等同于:
hex_chars db "0123456789ABCDEF"
irp arg, a, b, c, ...:irp指令后跟一个参数和一系列参数列表。参数(此处为arg)在每次迭代时代表一个单独的参数。例如,以下代码:
irp reg, eax, ebx, ecx {inc reg}
按顺序递增寄存器 EAX、EBX,然后是 ECX。
**irps arg, a b c ...**:irps指令与irp相同,区别在于参数列表中不使用逗号分隔。
包含指令
在前几章中,我们几乎没有触及的两条指令,看起来非常有用。这些指令是:
-
include 'filename' -
file 'filename'
包含指令
include指令的语法非常简单。它由指令本身后跟一个带引号的源文件名,表示我们要包含的文件。从逻辑上讲,它的操作类似于 C 或 C++中的#include关键字。在汇编编程中,事情并不总是那么简单,分割代码到多个源文件是一个很好的主意(例如,将所有宏指令定义放到一个单独的文件中),然后通过包含将它们组合到主源代码中。
文件指令
尽管在语法上,include和file指令是相似的,且都可以将一个文件包含到源代码处理当中,但在逻辑上它们非常不同。与include指令不同,file指令不会对被包含的文件进行任何处理。这使得将二进制数据包含到数据段或其他需要的地方成为可能。
概要
在本章中,我们简要介绍了汇编语言编程中宏指令的众多功能。不幸的是,可能需要一本完整的书籍来讨论宏指令的所有应用,尤其是当涉及到 Flat Assembler 时,它具有一个非常强大的预处理器。
一个来自我自身实践的例子:我曾经需要实现一个经过高度混淆的 AES128 解密算法版本,总共写了 2175 行,只有少数几个程序,而其中几乎一半(1064 行)被不同宏指令的定义所占据。正如你可以合理推测的那样,约 30%到 60%的每个程序都包含了宏指令的调用。
在下一章,我们将继续深入探讨预处理器,并处理不同的数据结构,以及其创建和管理方法。
第七章:数据结构
正如本书中已经多次提到的,汇编语言是关于对数据进行移动和执行某些基本操作,汇编编程是关于知道该将数据移动到哪里,并在此过程中对其应用哪些操作。到目前为止,我们主要集中在对不同类型数据执行的操作上,现在是时候讨论数据本身了。
在基于 Intel 架构的处理器中,最小的数据单元是比特,而最小的可寻址单元是字节(在 Intel 架构中是 8 位)。我们已经知道如何处理这样的数据,甚至是字、双字和单精度浮点值。然而,数据可能比这些更复杂,我指的不是四字、双精度浮点数等。
在本章中,我们将学习如何声明、定义和操作简单以及复杂的数据结构,以及这如何使我们作为汇编开发者的工作变得更加轻松。从简单的数据结构(如数组)开始,我们将逐步探讨包含不同数据类型的更复杂结构,并逐步过渡到链表和树形结构,最终介绍更复杂、更强大的数据排列方法。鉴于你作为开发者已经熟悉不同的数据结构,本章的目的是展示在汇编中使用它们的简便性,特别是使用 FASM 这款功能强大的汇编器。
本章中将讨论以下数据结构(数据排列方案):
-
数组
-
结构体
-
结构体数组
-
链表及其特殊情况
-
二叉搜索树及其平衡
-
稀疏矩阵
-
图
数组
到目前为止,我们已经走了很长一段路,主要处理从字节到四字的基本数据类型,为更复杂的数据相关概念做好准备。接下来我们将深入探讨数组,数组可以被视为相同类型数据的顺序存储。从理论上讲,数组成员的大小没有限制,但实际上我们受到诸如寄存器大小的限制。然而,存在一些变通方法,我们将在本章稍后看到。
简单字节数组
一个广泛使用且简单的数组的例子是 AES 算法中使用的正向替代表和/或反向替代表:
aes_sbox:
db 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5
db 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76
db 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0
db 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0
db 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc
db 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15
db 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a
db 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75
db 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0
db 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84
db 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b
db 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf
db 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85
db 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8
db 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5
db 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2
db 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17
db 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73
db 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88
db 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb
db 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c
db 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79
db 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9
db 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08
db 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6
db 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a
db 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e
db 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e
db 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94
db 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf
db 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68
db 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
正如我们可以清楚看到的,所有值的大小都是 1 字节,并且是按顺序一个接一个地存储。访问这样的数组非常简单,甚至可以通过 XLAT 指令完成。例如,假设我们正在进行 AES-128 计算,并且需要将每个字节替换为前面表格中的字节。假设以下是该值:
needs_substitution db 0, 1, 2, 3, 4, 5, 6, 7\
8, 9, 10, 11, 12, 13, 14, 15
以下代码将执行替代操作:
lea ebx, [aes_sbox]
lea esi, [needs_substitution] *; Set the source pointer (ESI) and*
mov edi, esi *; destination pointer (EDI) as we*
*; will be storing substituted*
; byte back
mov ecx, 0x10 *; Set the counter*
@@:
lodsb *; Load byte from the value*
xlatb *; Substitute byte from the s-box*
stosb *; Store new byte to the value*
loop @b *; Loop while ECX != 1*
我们做的第一件事是将表的基地址(S-box 的地址)加载到 EBX 寄存器中,因为 XLAT 指令正是使用这个寄存器来寻址替代/查找表。然后,我们加载需要的数组地址。
将数据替换到 ESI 寄存器中,以避免计算索引,因为 ESI 寄存器会被 lodsb 指令自动递增。将地址复制到 EDI 寄存器中,因为我们将把数据存回。
你也可以通过从最后一个字节到第一个字节处理 16 字节的值,加载 ESI 和 EDI 寄存器为 lea esi, [needs_substitution + 0x0f],将地址复制到 EDI,并使用 std 指令设置方向标志。完成后,别忘了使用 cld 指令清除方向标志。
然后,我们顺序读取值的每个字节,用 XLAT 指令将其替换为来自 S-box 的字节,并将结果存回。作为 XLAT 指令的替代方案(XLAT 指令限制为 256 字节的表,并且只能在 AL 寄存器依赖的字节值上操作),我们可以写出以下内容:
mov al, [aes_sbox + eax] *; aes_sbox is the base and EAX is the index*
然而,我们需要在进入循环之前将整个 EAX 寄存器置为 0,而 XLAT 允许 EAX 寄存器的高 24 位在整个操作过程中保持不变。
字数组、双字数组和四字数组
之前的简单示例展示了一个简单的字节数组,以及如何访问其成员。对于字数组、双字数组或四字数组,只需要做一些扩展,方法是:
-
我们不能在大于 256 字节的数组上使用 XLAT,也不能在数组成员大于 8 位时使用 XLAT。
-
我们需要使用 SIB 寻址(比例索引基址)来访问大于一个字节的数组成员。
-
在 32 位系统上,我们无法将一个四字节值读入单个寄存器。
为了简单起见,我们考虑使用一个查找表来计算 0 到 12 范围内数字的阶乘(此代码适用于 32 位,较大的数字的阶乘无法适应双字)。虽然阶乘计算的算法相当简单,但即使是如此短的范围,使用查找表要方便得多。
首先,将以下内容放入数据段(你也可以将其放入代码段,因为我们这里不会更改任何值,但让我们把数据与数据放在一起):
ftable dd 1,\ *; 0!*
1,\ *; 1!*
2,\ *; 2!*
6,\ *; 3!*
24,\ *; 4!*
120,\ *; 5!*
720,\ *; 6!*
5040,\ *; 7!*
40320,\ *; 8!*
362880,\ *; 9!*
3628800,\ *; 10!*
39916800,\ *; 11!*
479001600 *; 12!*
这是我们的查找表,包含了 13 个阶乘值,范围从 0 到 12,每个条目是双字(32 位)。现在,让我们编写一个过程来使用这个表。这个过程将按照stdcall调用约定实现;它接收一个参数,即我们需要计算阶乘的数字,并返回该数字的阶乘值,如果数字不在允许的范围内,则返回 0(因为 0 不能是阶乘的值)。将以下代码放入代码段:
factorial:
push ebp
mov ebp, esp
;-------------
virtual at ebp + 8 *; Assign a readable name to*
arg0 dd ? *; a location on stack where*
end virtual *; the parameter is stored*
;-------------
mov eax, [arg0] *; Load parameter from the stack*
cmp eax, 0x0c *; Check whether it is in range*
ja .oops *; Go there if not*
mov eax, [ftable + eax * 4] *; Retrieve factorial from*
*; the lookup table*
@@:
leave
ret 4
.oops:
xor eax, eax *; Set return value to 0*
jmp @b
virtual 指令允许我们在特定地址虚拟定义数据。在前面的例子中,我们定义了一个指向存储参数的堆栈位置的变量。在virtual块内定义的所有内容都被汇编器视为合法标签。在这种情况下,arg0 转换为 ebp + 8。如果我们有两个甚至更多通过堆栈传递给过程的参数,我们可以这样写:
virtual at ebp + 8
arg0 dd ?
arg1 dd ?
; 其余部分
end virtual
在这里,arg1 会被转换为 ebp+12,arg2(如果定义了的话)为 ebp+16,以此类推。
这个过程确实非常简单,它做的就是这个:
-
检查参数是否适合范围
-
如果参数不符合范围,则返回
0 -
使用参数作为查找表中的索引,并返回由表的基地址加上索引(我们的参数)乘以表中条目的大小所引用的值
结构体
作为开发者,我相信你会同意,大多数时候我们处理的不是统一数据的数组(我绝对不是低估常规数组的强大)。由于数据可以是任何东西,从 8 位数字到复杂结构体,我们需要一种方式来为汇编器描述这些数据,而“结构体”这一术语就是关键。Flat Assembler 和其他任何汇编器一样,允许我们声明结构体,并将其作为额外的数据类型(类似于 C 语言中的typedef结构)来使用。
让我们声明一个简单的结构体,即字符串表的一个条目,然后看看它是什么:
struc strtabentry [s]
{
.length dw .pad - .string *; Length of the string*
.string db s, 0 *; Bytes of the string*
.pad rb 30 - (.pad - .string) *; Padding to fill 30 bytes*
.size = $ - .length *; Size of the structure (valid*
*; in compile time only)*
}
结构体成员名前面的点符号 (.) 表示它们是更大命名空间的一部分。在这个具体的例子中,*.length*属于 strtabentry。
这样的声明在 C 语言中相当于以下声明:
typedef struct
{
short length;
char string[30];
}strtabentry;
然而,在 C 语言中,我们必须初始化类型为strtabentry的变量,如下所示:
*/* GCC (C99) */*
strtabentry my_strtab_entry = {.length = sizeof("Hello!"), .string = {"Hello!"} };
*/* MSVC */*
strtabentry my_strtab_entry = {sizeof("Hello!"), {"Hello!"} };
在汇编语言中,或者更准确地说,在使用 Flat Assembler 时,我们会以更简单的方式初始化这样的变量:
my_strtab_entry strtabentry "Hello!"
无论哪种方式,结构体的大小都是 32 字节(因为字符串缓冲区是静态分配的,大小为 30 字节),并且只有两个成员:
-
length:这是包含字符串长度的字长整数,加 1 以包含 null 终止符 -
string:这是实际的文本
访问结构体成员
关于如何访问结构体的各个成员,需要做一些说明。当结构体是静态分配时,我们可以通过它的标签/名称来引用结构体,这个标签会被转换成结构体的地址。例如,如果我们在数据段中定义了一个名为se的strtabentry结构,并且我们需要从字符串中读取第n个字节,那么我们所需要做的就是:
mov al, [se.string + n] *; 0 <= n < 30*
另一方面,如果我们不能使用标签(例如,在一个过程内,而结构体的指针是其参数),那么我们可以使用强大的virtual指令。作为快速演示,这里是一个返回字符串长度的过程,不包括终止的零字符:
get_string_length:
push ebp
mov ebp, esp
push ebx
*;=========*
virtual at ebp + 8 *; Give a name to the parameter on stack*
.structPtr dd 0 *; The parameter itself is not modified*
end virtual
*;---------*
virtual at ebx *; Give local name to the structure*
.s strtabentry 0 *; The structure is not really defined*
end virtual *; so the string is not modified*
*;=========*
mov ebx, [.structPtr] *; Load structure pointer to EBX*
mov ax, [.s.length] *; Load AX with the length*
movzx eax, ax * ; Upper 16 bits may still contain garbage*
*; so we need to clean it*
*dec eax ; Exclude null terminator*
pop ebx
leave
ret 4
为了帮助记忆,我们再看一下从栈中读取指针以及将字符串长度加载到 AX 寄存器的那几行。第一行如下所示:
mov ebx, [.structPtr]
前面的代码从栈中加载参数。正如我们所记得的,声明一个虚拟标签可以让我们为那些无法通过其他方式命名的内存位置赋予可读名称,而栈就是一个例子。在这个特定情况下,.structPtr 转换为 ebp + 8,因此该行代码等价于以下内容:
mov ebx,[ebp + 8]
同样,如果有第二个参数,虚拟声明将如下所示:
virtual at ebp + 8
.structPtr dd 0
.secondParam dd 0
end virtual
在这种情况下,读取第二个参数将如下所示:
mov ecx, [.secondParam]
另外,它将转化为以下内容:
mov ecx, [ebp + 12] *; Which is ebp + 8 + sizeof(.structPtr)*
这是我们感兴趣的第二行:
mov ax, [.s.length]
在这个特定情况下,我们正在访问结构体的第一个成员—.length,这意味着该行代码可以转换为以下内容:
mov ax, [ebx]
然而,如果我们需要访问字符串本身,例如,如果我们需要加载一个寄存器以获取字符串的地址,代码将如下所示:
lea eax, [.s.string]
这将转化为以下形式:
lea eax, [ebx + 2]
结构体数组
到现在为止,我们在访问结构体及其成员方面已经没有问题了,但如果我们有多个相同类型的结构体该怎么办?我们自然会将它们组织成一个结构体数组。看起来很简单,部分而言,确实是的。
为了简化访问数组成员的过程,我们可以使用指针数组,并通过某种查找表访问数组中的每个结构体。在这种情况下,我们只需使用以下方式从查找表中读取指针:
mov ebx, [lookup_table + ecx * 4] *; ECX contains the index into array*
*; of pointers and 4 is the scale (size*
*; of pointer on 32-bit systems)*
拥有指向感兴趣结构体的指针后,我们照常继续工作。
我们的示例结构非常方便,因为它的大小仅为 32 字节。如果我们将许多此类结构排列成一个数组,我们将能够轻松地访问一个包含 134,217,727 个成员的数组(在 32 位系统上),该数组占用 4GB 的内存。虽然我们几乎不会需要这么多最大长度为 30 字节的字符串(或者根本不需要这么多字符串),但在这种特定情况下,地址计算非常简单(再次强调,得益于结构体的舒适大小)。我们仍然使用结构体数组中的索引,但是由于不能利用 SIB 寻址的比例部分将索引按 32 字节进行缩放,我们需要在访问数组之前先对索引进行乘法运算。
让我们定义一个宏指令,用来首先创建这样的数组(同时构建指针查找表以供演示):
macro make_strtab strtabName, [strings]
{
common
label strtabName#_ptr dword *; The # operator concatenates strings*
local c *; resulting in strtabName_ptr*
c = 0
forward
c = c + 1 *; Count number of structures*
common
dd c *; Prepend the array of pointers with*
*; number of entries*
forward *; Build the pointer table*
local a
dd a
common *; Build the array of structures*
label strtabName dword
forward
a strtabentry strings
}
前面宏的调用,使用以下参数,形式如下:
make_strtab strtabName,\ *; Spaces are intentionally appended to*
"string 0",\ *; strings in order to provide us with*
"string 1 ",\ *; different lengths.*
"string 2 ",\
"string 3 "
这将导致内存中数据的以下排列:

如你所见,strtabName_ptr 变量包含数组中的结构体/指针数量,后面跟着四个指针的数组。接下来,在 strtabName 处(我们可以在调用宏时选择任何符合命名规则的名称),我们有了四个结构体的实际数组。
现在,如果我们需要检索结构体中索引为 2 的字符串长度(索引从 0 开始),我们将修改 get_string_length 程序,使其接受两个参数(结构体数组指针和索引),如下所示:
get_string_length:
push ebp,
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.structPtr dd ? *; Assign label to first parameter*
.structIdx dd ? *; Assign label to second parameter*
end virtual
virtual at ebx + ecx
.s strtabentry ? *; Assign label to structure pointer*
end virtual
mov ebx, [.structPtr] *; Load pointer to array of structures*
mov ecx, [.structIdx] *; Load index of the structure of interest*
shl ecx, 5 *; Multiply index by 32*
mov ax, [.s.length] *; Read the length*
movzx eax, ax
dec eax
pop ecx ebx
leave
ret 8
程序调用将如下所示:
push 2 *; push index on stack*
push strtabName *; push the address of the array*
call get_string_length
指向结构体的指针数组
前一小节向我们展示了如何处理均匀结构体的数组。由于没有特别的理由需要固定大小的字符串缓冲区,因此也没有必要使用固定大小的结构体。首先,我们需要对结构体声明做一点小修正:
struc strtabentry [s]
{
.length dw .pad - .string *; Length of the string*
.string db s, 0 *; Bytes of the string*
.size = $ - .length *; Size of the structure (valid*
*; in compile time only)*
}
我们只移除了 strtabentry 结构体中的 .pad 成员,使其可以具有可变大小。显然,我们不能再使用相同的 get_string_length 程序,因为我们没有固定的步长来遍历数组。但你可能已经注意到前面图像中的 strtabName_ptr 结构。这个结构就是用来帮助我们解决没有固定步长的问题的。我们可以重写 get_string_length 程序,使它接受一个指向结构体数组指针的指针,而不是直接接受数组指针和目标结构体的索引。修改后的程序如下所示:
get_string_length:
push ebp,
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.structPPtr dd ? *; Assign label to first parameter*
.structIdx dd ? *; Assign label to second parameter*
end virtual
virtual at ebx
.s strtabentry ? *; Assign label to structure pointer*
end virtual
mov ebx, [.structPPtr] *; Load pointer to array of structures*
mov ecx, [.structIdx] *; Load index of the structure of interest*
shl ecx, 2 *; Multiply index by 4 (size of pointer
* *; on a 32-bit platform
* cmp ecx, [.structPPtr] *; Check the index to fit the size of the
* *; array of pointers
* jae .idx_too_big *; Return error if* index exceeds the bounds
mov ebx, [ebx + ecx + 4]*; We have to add 4 (the size of int), in
* *; order to skip the number of structure
* *; pointers in the array*
mov ax, [.s.length] *; Read the length*
movzx eax, ax
.return:
dec eax
pop ecx ebx
leave
ret 8
.idx_too_big:
xor eax, eax *; The value of EAX would be -1 upon return*
jmp .return
完成!我们只需要做一些小的修改,添加这一行,再加上一行,现在我们就能够处理具有可变大小的结构体了。
到目前为止,内容并不复杂,接下来的内容也不难理解。虽然数据类型不多,但它的排列方式却有很多。结构体可以被视为一种数据类型,也可以看作是非均匀数据的排列方法,但为了方便起见,我们将其视为一个可以自由定义的数据类型。到现在为止,我们已经看到了当数据排列在静态内存中且排列不变时的情况,但是如果我们正在处理动态数据,而数据量在编写代码时无法确定该怎么办呢?在这种情况下,我们需要知道如何处理动态数据。这就引出了数据排列的下一个阶段——链表及其类型。
链表
链表顾名思义,由通过指针相互连接的数据项(节点)组成。基本上,链表有两种类型:
-
链表:每个节点都有指向下一个节点的指针
-
双向链表:每个节点有指向下一个节点和前一个节点的指针
以下图表展示了两者之间的区别:

两种类型的链表都可以通过几种方式进行寻址。显然,链表中至少有一个指向第一个节点的指针(称为top),可选地伴随有一个指向链表最后一个节点的指针(称为tail)。当然,若有需要,还可以添加多个辅助指针。节点中的指针字段通常称为next和previous。正如我们在图示中看到的,链表的最后一个节点以及双向链表中的第一个和最后一个节点都有next、previous和next字段,这些字段不指向任何地方——这样的指针被视为终止符,表示链表的结束,并且通常会填充null值。
在继续示例代码之前,让我们对本章使用的结构体做一个小改动,添加next和previous指针。结构体应该如下所示:
struc strtabentry [s]
{
.length dw .pad - .string
.string db s, 0
.pad rb 30 - (.pad - .string)
.previous dd ? *; Pointer to the next node*
.next dd ? *; Pointer to the previous node*
.size = $ - .length
}
我们将保留make_strtab宏不变,因为我们仍然需要一些东西来构建strtabentry结构体的集合;然而,我们将不再把它视为结构体数组。同时,我们将添加一个变量(类型为双字)来存储top指针。我们把它命名为list_top。
我们将不再编写一个宏指令来将四个结构体连接成一个双向链表,而是编写一个过程来向列表中添加新节点。这个过程需要两个参数——指向list_top变量的指针和指向我们想要添加到列表中的结构体的指针。如果我们是在 C 语言中编写,则对应函数的原型如下:
void add_node(strtabentry** top, strtabentry* node);
然而,由于我们并非在编写 C 语言,我们将写下以下代码:
add_node:
push ebp
mov ebp, esp
push eax ebx ecx
virtual at ebp + 8
.topPtr dd ?
.nodePtr dd ?
end virtual
virtual at ebx
.scx strtabentry ?
end virtual
virtual at ecx
.sbx strtabentry ?
end virtual
mov eax, [.topPtr] *; Load pointer to list_top*
mov ebx, [.nodePtr] *; Load pointer to new structure*
or dword [eax], 0 *; Check whether list_top == NULL*
jz @f *; Simply store the structure pointer*
*; to list_top if true*
mov ecx, [eax] *; Load ECX with pointer to current top*
mov [.scx.next], ecx *; node->next = top*
mov [.sbx.previous], ebx *; top->previous = node*
@@:
mov [eax], ebx *; top = node*
pop ecx ebx eax
leave
ret 8
现在,过程已经准备好,我们将从主过程调用它:
_start:
push strtabName + 40 *; Let the second structure be the first*
push list_top *; in the list*
call add_node
push strtabName + 120 *; Then we add fourth structure*
push list_top
call add_node
push strtabName + 80 *; Then third*
push list_top
call add_node
push strtabName *; And first*
push list_top
call add_node
第一、第二、第三和第四个指的是结构体在内存中的位置,而不是双向链表中节点的位置。因此,在执行前面代码的最后一行后,我们得到一个由strtabentry结构体组成的双向链表(通过其在链表中的位置显示){0, 2, 3, 1}。让我们通过以下截图来看一下结果的演示:

为了方便起见,结构体按其在内存中出现的顺序命名为struct_0、struct_1、struct_2和struct_3。最后一行是top指针list_top。如我们所见,它指向struct_0,这是我们最后添加到列表中的结构体,而struct_0反过来只包含一个指向下一个结构体的指针,同时它的previous指针的值为NULL。struct_0结构体的next指针指向struct_2,struct_2结构体的next指针指向struct_3,而previous指针则以相反顺序引导我们返回。
显然,链表(单向链表,无论是前向还是后向)比双向链表要简单一些,因为我们只需要处理节点中的单个指针成员。实现一个描述链表节点(无论是简单链表还是双向链表)的单独结构,并为创建/填充链表、查找节点和删除节点编写一套过程,可能是个好主意。以下结构就足够了:
*; Structure for a simple linked list node*
struc list_node32
{
.next dd ? *; Pointer to the next node*
.data dd ? *; Pointer to data object, which*
*; may be anything. In case data fits*
*; in 32 bits, the .data member itself*
*; may be used for storing the data.*
}
*; Structure for a doubly linked list node*
struc dllist_node32
{
.next dd ?
.previous dd ? *; Pointer to the previous node*
.data dd ?
}
如果你在编写长模式(64 位)的代码,那么唯一需要做的改变是将dd(表示 32 位双字)替换为dq(表示 64 位四字),以便能够存储长模式指针。
除此之外,你可能还想或需要实现一个描述整个链表的结构,拥有所有必要的指针、计数器等(在我们的示例中,它是list_top变量;虽然不是严格意义上的结构体,但它完成了任务)。然而,谈到链表数组时,使用指向链表的指针数组会更方便,因为这将使访问数组中的成员更加容易,从而使代码更少出错、更简单和更快速。
链表的特殊情况
除非你是自学成才的开发者,否则你很可能已经在编程课上听过很多除了数组和链表之外的不同数据结构,在这种情况下,你可能仍然听说过或读过这些内容。这里所指的不同数据结构是堆栈、队列、双端队列和优先队列。然而,作为奥卡姆剃刀原则的拥护者,我相信我们应该面对现实,承认所有这些都只是链表的特殊情况,除非它们的实现是基于数组的(在某些情况下这也可能是可行的)。
堆栈
堆栈是LIFO(后进先出)的数据排列方式。最简单的例子是进程/线程堆栈。尽管这种实现方式主要基于数组,但它很好地展示了这一机制。
然而,大多数时候,我们无法提前知道所需堆栈的大小,可能只能做一个大致估算。更不用说我们几乎不需要只存储双字或四字;我们大多数时候会有更复杂的结构。堆栈的最常见实现是一个仅由top指针管理的单向链表。理想情况下,堆栈上只允许进行三种操作:
-
push:用于向列表中添加一个新成员 -
top:用于查看/读取列表中最后添加的成员 -
pop:用于移除列表中最后添加的成员
虽然push和pop操作类似于在单向链表中添加和删除成员,但TOP操作基本上是获取top指针的值,从而访问链表中最上面的(最后添加的)成员。
队列与双端队列
队列正如名称所示,是一组元素的队列。链表通过两个指针进行访问——一个指向top元素,另一个指向tail元素。就本质而言,队列是FIFO(先进先出)的数据排列方式,这意味着最先入队的元素也会最先出队。队列的开始和结束完全由你决定——top是队列的开始还是结束,tail也是一样。如果我们希望将本章中使用的链表示例转换为队列,只需要添加一个list_tail指针。
双端队列是双向队列,这意味着元素可以根据算法从top元素或tail元素推入队列。同样地,弹出元素时也是如此。
优先队列
优先队列是常规队列的一种特例。唯一的区别是,加入其中的元素每个都有一定的优先级,这由算法定义,并根据需求来确定。其思想是,优先级高的元素先被服务,然后是优先级低的元素。如果两个元素具有相同的优先级,那么它们被服务的顺序是根据它们在队列中的位置来决定的,因此至少有两种可能的方式来实现这种排列。
一种实现方式是排序算法,它会根据元素的优先级来添加新元素。这仅仅是将双端队列转换为一个排序列表。
另一个方法是通过双端队列来寻找具有最高优先级的元素并优先服务它们,这使得双端队列与链表没有太大区别。唯一的区别,可能是元素只能被添加到top元素或tail元素。
循环链表
循环链表可能是仅次于单链表最容易实现的。两者之间的唯一区别是,链表的最后一个元素指向链表的第一个元素,而不是其next指针指向NULL。
链表特殊情况总结
正如我们所见,之前提到的链表的特殊情况实际上只是同一思想的不同逻辑范式。在汇编语言的情况下尤其如此,与更高级的语言(如 C 语言以上)不同,汇编语言没有内置的实现这些方法,因此它发挥了奥卡姆剃刀的作用,剔除了多余的概念,展示了低级现实中的事物。
然而,我们需要考虑阿尔伯特·爱因斯坦说过的话:
“一切事物应尽可能简单,但不能更简单。”
在将链表及其特殊情况尽可能简化后,我们需要继续处理更复杂、更强大的数据排列形式。在本章的下一节中,我们将介绍树——一种非常强大且有用的数据存储方法。
树
有时,我们已经覆盖的数据排列方案并不适合解决某些问题。例如,当处理一组经常被搜索或修改的数据,并且需要保持排序时,我们可以将它们放入数组或有序链表中,但搜索时间可能不理想。在这种情况下,最好将数据安排成树的形式。例如,二叉搜索树就是在搜索动态(变化的)数据时,最小化搜索时间的最佳方式。实际上,这同样适用于静态数据。
首先,什么是计算机中的树结构?谈到树结构时,人们可能会想到一种特殊类型的图(图将在本章后面简要介绍),它由一些节点组成,每个节点都有一个父节点(根节点除外,根节点通常称为“根节点”),并且可能有零个或多个子节点。在汇编语言中,我们可以像这样声明树节点的结构:
struc tnode dataPtr, leftChild, rightChild
{
.left dd leftChild *; Pointer to left node or 0*
.right dd rightChild *; Pointer to right node or 0*
.data dd dataPtr *; Pointer to data*
}
所以,我们有一个结构,它包含指向左子节点的指针(传统上,左子节点的值较小),指向右子节点的指针(传统上,右子节点的值较大),以及指向节点表示的数据的指针。通常来说,添加指向父节点的指针并不是一个坏主意,这有助于平衡树结构;然而,在本章接下来的例子中,我们并不需要这个指针。上面的节点结构就足够用于构建这样的树结构:

这张图展示了一个理想的平衡二叉搜索树的情况。然而,在实际情况中,这并不常见,而且取决于平衡方法。不幸的是,树的平衡方法稍微超出了本书的范围。不过,主要的思路是将较小的值放在左边,将较大的值放在右边,这通常涉及对子树,甚至是整个树,应用一定的旋转操作。
一个实际的例子
够了,别再讲枯燥的解释了。作为开发者,你很可能已经熟悉了树状结构及其平衡方法,或者至少听说过这些方法。相信通过实例学习是理解事物最有效的方式之一,我建议我们看一下下面的例子。
示例——简单的加密虚拟机
这个例子的思路广泛应用并且非常著名——一个简单的,不得不说是原始的,虚拟机。假设我们需要实现一个虚拟机,用一个单字节的密钥,通过异或操作来执行简单的字符串加密。
虚拟机架构
虚拟处理器的架构相当简单——它有几个寄存器,用于存储当前的执行状态:
| 寄存器名称 | 寄存器功能 |
|---|---|
register_a |
一个 8 位通用寄存器。该寄存器可以被虚拟机代码访问。 |
register_b |
一个 8 位通用寄存器,该寄存器可以被虚拟机代码访问。 |
register_key |
一个 8 位寄存器,存储加密密钥字节。 |
register_cnt |
一个 8 位寄存器,存储vm_loop指令的计数器。该寄存器可以被虚拟机代码访问。 |
data_base |
一个 32 位寄存器(长模式下为 64 位寄存器)。存储要加密数据的地址。 |
data_length |
一个 32 位寄存器,存储要加密数据的长度(仅使用 8 位,因此数据不能超过 256 字节)。 |
虚拟处理器的指令集非常有限,但它们并不是按顺序编码的:
| 操作码 | 助记符 | 含义 |
|---|---|---|
| 0x00 | vm_load_key |
将虚拟机过程的key参数加载到虚拟处理器的key寄存器中。 |
| 0x01 | vm_nop |
这是 NOP 指令,表示不执行任何操作。 |
| 0x02 | vm_load_data_length |
将要加密的字符串长度加载到虚拟处理器的data length寄存器中。 |
| 0x10 | vm_loop target |
如果counter寄存器小于data length寄存器,则跳转到target。 |
| 0x11 | vm_jump target |
无条件跳转到target地址。 |
| 0x12 | vm_exit |
通知虚拟处理器停止运行。 |
| 0x20 | vm_encrypt regId |
对register[regId]的内容和key寄存器的内容进行异或操作。 |
| 0x21 | vm_decrement regId |
递减register[regId]的内容。 |
| 0x22 | vm_increment regId |
递增register[regId]的内容。 |
| 0x30 | vm_load_data_byte regId |
从data_base_address + counter_register加载字节到register[regId]中。 |
| 0x31 | vm_store_data_byte regId |
将register[regId]中的字节存储到data_base_address + counter_register中。 |
向 Flat Assembler 添加虚拟处理器的支持
我们将跳过为处理器声明单独结构的步骤;相反,处理器的状态将存储在堆栈中。不过,我们需要做一些准备工作。首先,我们需要让 Flat Assembler 理解我们的助记符并生成适当的二进制输出。为此,我们将创建一个附加的源文件,并命名为vm_code.asm。由于该文件将包含宏指令的声明和虚拟机代码(它们将作为数据处理),因此要在主源文件中包含此文件,可以通过添加以下内容:
include 'vm_code.asm'
在数据部分的某个位置添加这一行。下一步,我们必须定义可以转换为虚拟处理器理解的二进制输出的宏指令。这是 FASM 的一个非常强大的功能,因为人们可以通过一组宏指令为几乎任何架构添加支持(顺便提一下,这正是 Flat Assembler G 的核心思想):
macro vm_load_key
{
db 0x00
}
macro vm_nop
{
db 0x01
}
macro vm_load_data_length
{
db 0x02
}
macro vm_loop loopTarget
{
db 0x10
dd loopTarget - ($ + 4)
}
macro vm_jump jumpTarget
{
db 0x11
dd loopTarget - ($ + 4)
}
macro vm_exit
{
db 0x12
}
macro vm_encrypt regId
{
db 0x20
db regId
}
macro vm_decrement regId
{
db 0x21
db regId
}
macro vm_increment regId
{
db 0x22
db regId
}
macro vm_load_data_byte regId
{
db 0x30
db regId
}
macro vm_store_data_byte regId
{
db 0x31
db regId
}
*; Let's give readable names to registers*
register_a = 0
register_b = 1
register_cnt = 2
虚拟代码
显然,我们写前面的所有代码不是为了好玩;我们需要为虚拟处理器编写一些代码。由于架构非常有限且专门针对特定任务,因此代码的形式选择不多:
*; Virtual code ; Binary output*
vm_code_start:
vm_load_key *; 0x00*
vm_load_data_length *; 0x02*
vm_nop *; 0x01*
.encryption_loop:
vm_load_data_byte register_b *; 0x30 0x01*
vm_encrypt register_b *; 0x20 0x01*
vm_store_data_byte register_b *; 0x31 0x01*
vm_loop .encryption_loop *; 0x10 0xf5 0xff 0xff 0xff*
vm_exit *; 0x12*
虚拟处理器
到目前为止,一切似乎都很清楚,除了一个问题——这一切与树有什么关系?我们快到了,因为我们必须实现虚拟处理器本身,这就是我们在这里要做的。
虚拟处理器最简单且可能最常见的实现是while()循环,它通过读取虚拟机内存中的指令运行,并通过实现为间接跳转和跳转表(跳转目标地址表)的switch()语句来选择合适的执行路径。尽管我们的示例可能在这种方式下运行效果最好,而且下面描述的架构更适合复杂指令集,但它故意简化以避免讨论那些与树形结构明显无关的方面。
如指令/操作码表所示,我们的操作码都是 1 字节大小,再加上一个 1 字节或 4 字节的操作数(对于需要操作数的指令),范围从0x00到0x31,并且有相对较大的间隔。然而,操作码的数量使我们可以将它们安排成一个几乎完美的二叉搜索树:

我们说“几乎”是因为如果每个表示操作码0x11(vm_jump)和0x20(vm_encrypt)的节点都有两个子节点,那么它将是一个理想的二叉搜索树(但谁说我们不能再添加四个指令呢?)。
图中的每个节点代表一个tnode结构,包含所有必要的指针,包括一个指向小结构的指针,该结构将操作码映射到虚拟处理器循环中的真实汇编代码:
struc instruction opcode, target
{
.opcode dd opcode
.target dd target
}
因此,首先要做的就是建立一个将所有操作码映射到汇编代码的表格。表格的格式相当简单。每行包含以下内容:
-
双字操作码
-
一个指向汇编代码的指针(32 位模式为双字,64 位模式为长模式)。
在代码中实现表格相当简单:
i_load_key instruction 0x00,\
run_vm.load_key
i_nop instruction 0x01,\
run_vm.nop
i_load_data_length instruction 0x02,\
run_vm.load_data_length
i_loop instruction 0x10,\
run_vm.loop
i_jump instruction 0x11,\
run_vm.jmp
i_exit instruction 0x12,\
run_vm.exit
i_encrypt instruction 0x20,\
run_vm.encrypt
i_decrement instruction 0x21,\
run_vm.decrement
i_increment instruction 0x22,\
run_vm.increment
i_load_data_byte instruction 0x30,\
run_vm.load_data_byte
i_store_data_byte instruction 0x31,\
run_vm.store_data_byte
最后,我们已经到达了树。我们跳过树的构建和平衡过程,因为树是静态分配的,而且我们特别关注的是结构本身。在下面的代码中,我们实际上创建了一个tnode结构的数组,这些结构并不是通过base+index来访问,而是通过树进行连接。最后一行定义了一个指向树根节点tree_root的指针,它指向t_exit:
t_load_key tnode i_load_key,\ ; 0x00 <-\
0,\ ; |
0 ; |
t_nop tnode i_nop,\ ; 0x01 | <-\
t_load_key,\ ; ---------/ |
t_load_data_length ; ---------\ |
t_load_data_length tnode i_load_data_length,\ ; 0x02 <-/ |
0,\ ; |
0 ; |
t_loop tnode i_loop,\ ; 0x10 | <-\
t_nop,\ ; -------------/ |
t_jmp ; --------\ |
t_jmp tnode i_jump,\ ; 0x11 <-/ |
0,\ ; |
0 ; |
t_exit tnode i_exit,\ ; 0x12 |
t_loop,\ ; -----------------/
t_decrement ; --------\
t_encrypt tnode i_encrypt,\ ; 0x20 | <-\
0,\ ; | |
0 ; | |
t_decrement tnode i_decrement,\ ; 0x21 <-/ |
t_encrypt,\ ; ------------/
t_load_data_byte ; --------\
t_increment tnode i_increment,\ ; 0x22 | <-\
0,\ ; | |
0 ; | |
t_load_data_byte tnode i_load_data_byte,\ ; 0x30 <-/ |
t_increment,\ ; ------------/
t_store_data_byte ; --------\
t_store_data_byte tnode i_store_data_byte,\ ; 0x31 <-/
0,\
0
tree_root dd t_exit
编译后,执行文件的数据部分看起来是这样的:

搜索树
我们需要处理一个过程,该过程会在开始实现虚拟处理器循环之前从树中提取虚拟指令的汇编实现的正确地址。
tree_lookup过程需要两个参数:
-
tree_root变量的地址 -
将字节操作码转换为双字(double word)。
当此过程被调用时,它会按照树排序的规则逐个节点地“遍历”树,并将操作码参数与当前节点所引用的指令结构中的操作码值进行比较。该过程返回操作码的汇编实现地址,若未定义该操作码,则返回零:
tree_lookup:
push ebp
mov ebp, esp
push ebx ecx
virtual at ebp + 8
.treePtr dd ? *; First parameter - pointer to tree_root*
.code dd ? *; Second parameter - opcode value*
end virtual
virtual at ecx
.node tnode ?,?,? *; Lets us treat ECX as a pointer*
*; to tnode structure*
end virtual
virtual at eax
.instr instruction ?, ? *; Lets us treat EAX as a pointer*
*; to instruction structure*
end virtual
mov ecx, [.treePtr] *; Load the pointer to tree_root*
mov ecx, [ecx] *; Load the pointer to root node*
mov ebx, [.code] *; Read current opcode*
movzx ebx, bl *; Cast to unsigned int*
@@:
or ecx, 0 *; Check whether ECX points to a node*
jz .no_such_thing *; and return zero if not*
mov eax, [.node.data] *; Load pointer to instruction structure*
cmp ebx, [.instr.opcode] *; Compare opcode value*
jz @f
ja .go_right *; If node contains lower opcode, then*
*; continue searching the right subtree*
mov ecx, [.node.left] *; Otherwise continue searching the*
jmp @b *; left subtree*
.go_right:
mov ecx, [.node.right]
jmp @b
@@:
mov eax, [.instr.target] *; Relevant instruction structure has*
*; been found, so return the address*
*; of instruction implementation*
@@:
pop ecx ebx *; We are done*
leave
ret 8
.no_such_thing: *; Zero out EAX to denote an error*
xor eax, eax
jmp @b
循环
循环的实现稍微有些长,并且我们有许多其他有趣的内容填充本章的空间,因此请参考附带的源代码获取完整版本。不过,在这里我们将检查实现的某些部分:
- 创建栈帧和参数标记:该过程的前导代码和往常一样——我们在栈上分配一些空间,并保存那些我们希望在过程执行过程中不受影响的寄存器,也就是过程中的所有寄存器:
run_vm:
push ebp
mov ebp, esp
sub esp, 4 * 3 *; We only need 12 bytes for storing*
*; the state of virtual cpu*
push eax ebx ecx edx esi *; We will use these registers*
virtual at ebp + 8 *; Assign local labels to parameters*
.p_cmd_buffer_ptr dd ? *; Pointer to VM code*
.p_data_buffer_ptr dd ? *; Pointer to data we want to
; encrypt*
.p_data_length dd ? *; Length of data in bytes*
.p_key dd ? *; Key value cast to double word*
end virtual
virtual at ebp - 0x0c *; Assign local labels to stack
; variables*
.register_a db ? *; Register A of virtual processor*
.register_b db ? *; Register B of virtual processor*
.register_key db ? *; Register to hold the key*
.register_cnt db ? *; Counter register*
.data_base dd ? *; Pointer to data buffer*
.data_length dd ? *; Size of the data buffer in size*
end virtual
- 准备虚拟处理器循环:该循环本身首先从当前虚拟代码的位置读取操作码(opcode),然后调用
tree_lookup过程,若tree_lookup返回错误(零),则跳转至.exit,否则跳转至tree_lookup返回的地址:
virtual_loop:
mov al, [esi + ebx] *; ESI - points to array of bytes
; containing*
*; virtual code*
*; EBX - instruction pointer (offset
; into virtual code)*
movzx eax, al *; Cast opcode to double word*
push eax
push tree_root
call tree_lookup *; Get address of opcode emulation
; code*
or eax, 0 *; Check for error*
jz .exit
jmp eax *; Jump to emulation code*
上述代码后面是模拟代码片段的指令集,如附带源代码中所示。
run_vm过程的最后几行实际上是vm_exit操作码的仿真:
.exit:
pop esi edx ecx ebx eax *; Restore saved registers*
add esp, 4 * 3 *; Destroy stack frame*
leave
ret 4 * 4
树平衡
现在,当我们知道了二叉搜索树在汇编编程级别上的样子时,若不回到二叉搜索树平衡的问题上就是不正确的。这个问题有几种解决方法,但我们只考虑其中一种——Day-Stout-Warren 算法(包含在附带的代码中)。该算法非常简单:
-
分配一个树节点,并将其作为树的“伪根”,使得原始根节点成为伪根的右子节点。
-
通过中序遍历将树转换为排序的链表(此步骤还会计算原树中的节点数量)。不需要额外的分配,因为此步骤会重用树节点中已有的指针。
-
将链表重新转换为完整的二叉树(其中最底层的节点从左到右严格填充)。
-
使伪根的右子节点成为树的根。
-
处理伪根节点。
将此算法应用于我们的操作码树将会得到以下结构:

结构几乎保持不变——四个层级,包括根节点,以及最底层的四个节点。操作码的顺序有所变化,但在这个特定的例子中,这并不太重要。然而,如果我们设计一个期望承载更大负载的更复杂系统,我们可以将操作码的编码设计成这样:最常用的操作码使用上层的值进行编码,而最不常用的操作码则使用底层的值。
稀疏矩阵
稀疏矩阵很少被讨论,如果有的话,是因为它们的实现和维护相对复杂;然而,在某些情况下,它们可能是一个非常方便和有用的工具。基本上,稀疏矩阵在概念上与数组非常相似,但它们在处理稀疏数据时效率更高,因为它们节省内存,从而使得可以处理更大规模的数据。
以天文摄影为例。对于我们这些不熟悉这个领域的人来说,业余天文摄影意味着将数码相机连接到望远镜,选择夜空中的某个区域并拍摄照片。然而,由于拍摄是在没有手电筒或任何其他辅助设备的情况下进行的(其实用手电筒照亮天体是很愚蠢的做法),所以需要拍摄几十张相同物体的照片,然后使用特定算法将这些图像堆叠在一起。在这种情况下,存在两个主要问题:
-
噪声抑制
-
图像对齐
缺乏专业设备(即没有配备冷却 CCD 或 CMOS 矩阵的大型望远镜),就会面临噪声问题。曝光时间越长,最终图像中的噪声就越多。当然,有许多噪声抑制算法,但有时,某些真实的天体可能会被错误地当作噪声,并被噪声抑制算法去除。因此,处理每一张图像并检测潜在的天体是个好主意。如果某个“光点”,如果没有被认为是噪声,至少在 80%的图像中出现(很难相信任何噪声能够在没有变化的情况下存活这么长时间,除非我们在谈论坏点),那么这个区域需要不同的处理。
然而,为了处理图像,我们需要决定如何存储结果。当然,我们可以使用一个结构数组来描述每一个像素,但这样做在内存方面的开销太大。另一方面,即使我们拍摄的是夜空中人口密集的区域,天体所占的区域也远小于“空白”空间。相反,我们可以将图像划分成较小的区域,分析这些较小区域的某些特征,并且只考虑那些看起来被填充的区域。下图展示了这个想法:

该图(展示了梅西耶 82 天体,也被称为雪茄星系)被划分为 396 个较小的区域(一个 22 x 18 的矩阵,每个区域为 15 x 15 像素)。每个区域可以通过其亮度、噪声比以及许多其他方面来描述,包括它在图中的位置,这意味着它可能占用相当可观的内存。如果将这些数据存储在一个二维数组中,并同时存储超过 30 张图像,可能会产生数兆字节的无意义数据。正如图中所示,只有两个感兴趣的区域,它们共同构成约 0.5%的数据(这更完美地符合稀疏数据的定义),这意味着如果我们选择使用数组,我们将浪费 99.5%的内存。
利用稀疏矩阵,我们可以将内存的使用减少到仅存储重要数据所需的最小值。在这种特定情况下,我们将有一个 22 列头节点、18 行头节点的链表,并且只有 2 个数据节点。以下是这种排列的一个非常粗略的示例:

前面的示例非常粗略;实际上,实施中还会包含一些其他链接。例如,空列头节点的down指针会指向它自身,空行头节点的right指针也会指向它自身。行中的最后一个数据节点的right指针会指向行头节点,同样,列中的最后一个数据节点的down指针会指向列头节点。
图
图的一般定义是,图是由一组顶点(V)和边(E)组成的数据结构。顶点可以是任何东西(任何东西意味着任何数据结构),边则由它连接的两个顶点-v和w来定义。边有方向,这意味着数据从顶点v流向顶点w,并且有权重,表示流动的难度。
最简单且可能是最常见的图结构示例是感知机——一种人工神经网络范式:

传统上,感知机是从左到右绘制的,因此我们有三个层:
-
输入层(传感器)
-
隐藏层(大多数处理发生的地方)
-
输出层(形成感知机的输出)
尽管人工神经网络的节点被称为神经元,但由于我们讨论的是图,因此我们将它们称为顶点,而不是 ANN(人工神经网络)。
在前面的图中,我们看到一个典型的多层感知机布局,用于解决 XOR 问题的人工神经网络。
人工神经网络中的 XOR 问题是指使得一个 ANN 实现能够接收两个在 {0, 1} 范围内的输入并产生一个结果,仿佛两个输入进行了异或操作。单层感知机(其中隐藏层也是输出层)无法找到该问题的解决方案,因此需要添加额外的层。
顶点S0和S1不执行任何计算,它们作为顶点N0和N1的数据源。正如所述,边具有权重,在这个示例中,来自S0和S1的数据会与边的权重进行相乘,边的权重包括 [s0, n0]、[s0, n1]、[s1, n0] 和 [s1, n1]。同样的操作适用于通过 [bias, n0]、[bias, n1]、[n0, o] 和 [n1, o] 传输的数据。
然而,图形可以是任意形状,边缘可以将数据传递到任何方向(甚至传递到同一顶点),具体取决于它们要解决的问题。
摘要
在本章中,我们简要介绍了几种数据结构(不要与汇编中的 struc[tures] 混淆)并回顾了它们的一些可能应用。然而,由于数据结构的主题非常广泛,可能需要为这里简要描述的每种结构及其变种单独开设章节,这不幸超出了本书的范围。
从下一章(第八章,将汇编语言编写的模块与高级语言编写的模块混合)开始,我们将解决更多实际问题,并开始应用迄今为止所学的知识,力求找到优雅的解决方案。
在下一章中,我们将看到如何将为 32 位和 64 位 Windows 及 Linux 操作系统编写的汇编代码与现有的汇编或高级语言编写的库链接。我们甚至会讨论.NET 与汇编代码的互操作性(在 Linux 和 Windows 上均适用)。
第八章:混合使用用汇编语言编写的模块和用高级语言编写的模块
我们已经走了很长一段路,几乎涵盖了汇编语言编程基础的各个方面。事实上,到目前为止,我们应该能够用汇编语言实现任何算法;然而,还有一些重要的内容我们尚未涉及,但这些内容同样重要。
尽管在产品开发的时间表上,用汇编语言编写较大部分(甚至是整个产品)可能不是最佳选择,但它仍然是一个非常有趣且具有挑战性的任务(也具有教育意义)。有时,使用汇编语言实现某些算法的部分,可能比使用高级语言更为方便。还记得我们用来进行异或加密的微型虚拟机吗?为了举例说明,我们将在汇编语言中实现一个简单的加密/解密模块,并看看它如何与高级语言一起使用。
在本章中,我们将涵盖以下主题:
-
实现一个简单的加密模块核心
-
为进一步与高级语言编写的代码进行链接,构建目标文件:
-
OBJ:适用于 Windows 的目标文件(32 位和 64 位);
-
O:适用于 Linux 的可链接 ELF(32 位和 64 位);
-
-
为 Windows 和 Linux(32 位和 64 位)构建 DLL(动态链接库)和 SO(共享对象),以便在.NET 平台上使用
加密核心
本章的主要项目是一个完全用汇编语言编写的小型简单(不能说是原始的)加密/解密模块。由于本章的主题是汇编语言模块与高级语言模块的接口,我们不会深入讨论加密原理,而是将重点放在代码的可移植性和互操作性上,同时使用稍微修改过的异或算法。该算法的基本思想是接收一个字节数组并执行以下操作:
-
获取一个字节,并将其左移指定的位数(计数器在编译时随机生成)。
-
用 1 字节的密钥(在编译时随机生成)对结果进行异或操作。
-
将字节写回数组。
-
如果有更多字节需要加密,回到步骤 1;否则跳出循环。
以下截图是我们即将实现的算法的一个输出示例:

这不是最好的加密方式,但对于我们的需求来说绝对足够。
可移植性
我们的目标是编写可以在 32 位和 64 位 Windows 以及 Linux 平台上使用的可移植代码。这个目标可能听起来不可能实现,或者是非常繁琐的工作,但其实非常简单。首先,我们需要定义一些常量和宏,这将使我们的后续工作更加轻松,所以让我们从创建platform.inc和crypto.asm源文件开始,其中后者是主要的源文件,也是我们将要编译的文件。
Flat Assembler 能够生成多种格式的文件,从原始二进制输出和 DOS 可执行文件,到 Windows 特定格式,再到 Linux 二进制文件(包括可执行文件和对象文件)。假设你至少熟悉以下几种格式:
-
32 位 Windows 对象文件(MS COFF 格式)
-
64 位 Windows 对象文件(MS64 COFF 格式)
-
32 位 Windows DLL
-
64 位 Windows DLL
-
32 位 Linux 对象文件(ELF)
-
64 位 Linux 对象文件(ELF64)
不需要深入了解它们,因为 Flat Assembler 为我们完成了所有繁重的工作,我们所要做的就是告诉它我们感兴趣的格式(并相应地格式化我们的代码)。我们将使用一个编译时变量ACTIVE_TARGET进行条件编译,并使用以下常量作为可能的值:
; Put this in the beginning of 'platform.inc'
type_dll equ 0
type_obj equ 1
platform_w32 equ 2
platform_w64 equ 4
platform_l32 equ 8
platform_l64 equ 16
TARGET_W32_DLL equ platform_w32 or type_dll
TARGET_W32_OBJ equ platform_w32 or type_obj
TARGET_W64_DLL equ platform_w64 or type_dll
TARGET_W64_OBJ equ platform_w64 or type_obj
TARGET_L32_O equ platform_l32 or type_obj
TARGET_L64_O equ platform_l64 or type_obj
指定输出格式
和往常一样,主源文件(在我们这个例子中是crypto.asm)应该以输出格式规范开始,从而告诉汇编器在创建输出文件时如何处理代码和段。正如我们之前提到的,编译时变量ACTIVE_TARGET将用于选择汇编器处理的正确代码。
下一步将是定义一个宏,该宏将有条件地生成正确的代码序列。我们将其命名为set_output_format:
macro set_output_format
{
if ACTIVE_TARGET = TARGET_W32_DLL
include 'win32a.inc'
format PE DLL
entry DllMain
else if ACTIVE_TARGET = TARGET_W32_OBJ
format MS COFF
else if ACTIVE_TARGET = TARGET_W64_DLL
include 'win64a.inc'
format PE64 DLL
entry DllMain
else if ACTIVE_TARGET = TARGET_W64_OBJ
format MS64 COFF
else if ACTIVE_TARGET = TARGET_L32_O
format ELF
else if ACTIVE_TARGET = TARGET_L64_O
format ELF64
end if
}
这个宏会告诉汇编器评估ACTIVE_TARGET编译时变量,并且只使用特定的代码。例如,当ACTIVE_TARGET等于TARGET_W64_OBJ时,汇编器将只处理以下行:
format MS64 COFF
因此,它将生成一个 64 位 Windows 对象文件。
条件声明代码和数据段
在告诉编译器我们期待什么输出格式后,我们需要声明各个段。由于我们在编写可移植代码,因此我们将使用两个宏来为前面提到的每种格式正确声明代码段和数据段。由于我们习惯在代码段后看到数据段(至少在本书中是这样,顺序可能会有所不同),我们将首先声明一个宏,负责正确声明代码段的开始:
macro begin_code_section
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.text' code readable executable
*; This is not obligatory, but nice to have - the DllMain procedure*
DllMain:
xor eax, eax
inc eax
ret 4 * 3
else if ACTIVE_TARGET = TARGET_W32_OBJ
section '.text' code readable executable
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.text' code readable executable
*; DllMain procedure for 64-bit Windows DLL*
DllMain:
xor rax, rax
inc eax
ret
else if ACTIVE_TARGET = TARGET_W64_OBJ
section '.text' code readable executable
else if ACTIVE_TARGET = TARGET_L32_O
section '.text' executable
else if ACTIVE_TARGET = TARGET_L64_O
section '.text' executable
end if
}
我们接下来会声明数据段的宏:
macro begin_data_section
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W32_OBJ
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.data' data readable writeable
else if ACTIVE_TARGET = TARGET_W64_OBJ
section '.data' data readable writeable align 16
else if ACTIVE_TARGET = TARGET_L32_O
section '.data' writeable
else if ACTIVE_TARGET = TARGET_L64_O
section '.data' writeable
end if
}
导出符号
系列中的最后一个宏将使得某些符号得以导出。我们实现的加密核心将只导出一个符号——GetPointers()过程——它将返回一个指向结构的指针,结构包含指向其他过程的指针。这个宏遵循之前定义的模式:
*; In this specific case, when the macro would be called*
*; at the end of the source, we may replace the*
*; "macro finalize" declaration with the "postpone" directive.*
macro finalize
{
if ACTIVE_TARGET = TARGET_W32_DLL
section '.edata' export data readable
export 'MA_CRYPTO.DLL',\
GetPointers, 'GetPointers'
else if ACTIVE_TARGET = TARGET_W32_OBJ
public GetPointers as '_GetPointers'
else if ACTIVE_TARGET = TARGET_W64_DLL
section '.edata' export data readable
export 'MA_CRYPTO.DLL',\
GetPointers, 'GetPointers'
else if ACTIVE_TARGET = TARGET_W64_OBJ
public GetPointers as 'GetPointers'
else if ACTIVE_TARGET = TARGET_L32_O
public GetPointers as 'GetPointers'
else if ACTIVE_TARGET = TARGET_L64_O
public GetPointers as 'GetPointers'
end if
}
上面的宏会使符号对静态或动态链接器可见,具体取决于我们正在构建的目标。或者,我们可以用postpone指令替换macro finalize,这将强制在源文件结束时自动执行宏体。
现在我们可以保存platform.inc文件,因为我们在未来不会以任何方式修改它。
核心过程
在处理了所有输出格式的细节后,我们可以安全地继续实现核心代码。正如之前提到的,我们只需导出一个入口;但我们仍需实现其他部分。我们的核心中只有四个过程:
-
f_set_data_pointer:此过程接受一个参数,即指向我们要处理的数据的指针,并将其存储到data_pointer全局变量中 -
f_set_data_length:此过程接受一个参数,即我们要加密/解密的数据长度,并将其存储到data_length全局变量中 -
f_encrypt:此过程实现了加密循环 -
f_decrypt:这是f_encrypt的反操作
然而,在实现这些之前,我们首先需要准备模板,或者更准确地说,为我们的主源文件准备一个框架。由于宏指令的广泛使用,这个模板看起来与我们习惯的稍有不同。但不要让它让你困惑,从结构上来说(从汇编语言工程师的角度看)它与我们之前处理的结构是相同的:
*; First of all we need to include all that we have written this far*
include 'platform.inc'
*; The following variable and macro are used in compile time
; only for generation of* *pseudorandom sequences, where
; count specifies the amount of pseudorandom bytes to* *generate*
seed = %t
macro fill_random count
{
local a, b
a = 0
while a < count
seed = ((seed shr 11) xor (seed * 12543)) and 0xffffffff
b = seed and 0xff
db b
a = a + 1
end while
}
*; ACTIVE_TARGET variable may be set to any of the
; TARGET* constants*
ACTIVE_TARGET = TARGET_W32_DLL
*; Tell the compiler which type of output is expected
; depending on the value of* *the ACTIVE_TARGET variable*
set_output_format
*; Create code section depending on selected target*
begin_code_section
*; We will insert our code here*
*; Create appropriate declaration of the data section*
begin_data_section
*; Tell the compiler whether we are expecting 32-bit
; or 64-bit output*
if(ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
use32
else if(ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
use64
end if
*; This, in fact, is a structure which would be populated with
; addresses of our procedures*
pointers:
fill_random 4 * 8
*; Here the core stores the address of the data to be processed*
data_pointer:
fill_random 8
*; And here the core stores its length in bytes*
data_length:
fill_random 8
*; Pseudorandom encryption key*
key:
fill_random 2
*; The following line may be omitted if we used the postpone*
*; directive instead of "macro finalize"*
finalize
尽管前面的代码看起来与我们通常看到的有所不同,但它其实是自解释的,不需要额外的说明。所有的艰难工作都交给了之前定义的宏指令,唯一需要我们关注的就是位容量。正如你所看到的,大小和地址默认分配了 8 字节。这是为了使它们适应 32 位和 64 位的需求。我们本可以插入另一个if…else语句,但由于我们只有 3 个受位容量影响的数据项,在 32 位模式下每个数据项多占用 4 字节也不成问题。
加密/解密
由于我们在这里开发的是加密核心,因此自然要先实现加密功能。以下代码根据我们之前定义的算法执行数据加密:
f_encrypt:
*; The if statement below, when the condition is TRUE, forces the assembler to produce*
*; 32-bit code*
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push eax ebx esi edi ecx
lea esi, [data_pointer]
mov esi, [esi]
mov edi, esi
lea ebx, [data_length]
mov ebx, [ebx]
lea ecx, [key]
mov cx, [ecx]
and cl, 0x07
*; Encryption loop*
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec ebx
or ebx, 0
jnz @b
pop ecx edi esi ebx eax
ret
*; In general, we could have omitted the "if" statement here,
; but the assembler*
*; should not generate any code at all, if
; the value of ACTIVE_TARGET is not valid.*
*; In either case, the following block is processed only
; when we are expecting* *a 64-bit output*
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
push rax rbx rsi rdi rcx
lea rsi, [data_pointer]
mov rsi, [rsi]
mov rdi, rsi
lea rbx, [data_length]
mov ebx, [rbx]
lea rcx, [key]
mov cx, [rcx]
and cl, 0x07
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec rbx
or rbx, 0
jnz @b
pop rcx rdi rsi rbx rax
ret
end if
到现在为止,你应该能够自己区分过程的不同部分,看到哪里是前导代码的结束,哪里是尾部代码的开始,以及核心功能所在的位置。在这个特定的案例中,大部分代码都用于保存/恢复寄存器和访问参数/变量,而核心功能可以归结为以下代码:
*; Encryption loop*
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec ebx
or ebx, 0
jnz @b
用于 32 位平台,或者这段代码:
@@:
lodsb
rol al, cl
xor al, ch
stosb
dec rbx
or rbx, 0
jnz @b
用于其 64 位平台。
很明显,解密过程的实现将与加密过程几乎完全相同,唯一的变化就是交换旋转和XOR指令(当然还需要改变旋转方向)。因此,f_decrypt的 32 位版本会是这样的:
xor al, ch
ror al, cl
同样,它的 64 位版本也只是这两行代码。
设置加密/解密参数
正如你可能已经注意到的(希望你已经注意到),上一节讨论的过程完全没有接收任何参数。因此,我们确实需要提供两个额外的过程,以便能够告诉核心数据的位置以及需要处理多少字节。由于每个过程只接受一个参数,代码将更加分段,以便反映所使用的调用约定,在我们的情况下,调用约定如下:
-
适用于 32 位目标的 cdecl
-
适用于基于 Windows 的 64 位目标的 Microsoft x64
-
适用于基于 Linux 的 64 位目标的 AMD64
f_set_data_pointer
这个过程接收一个 void* 类型的参数。当然,汇编器并不关心某个过程期望的参数类型。更准确地说,汇编器作为编译器,并不理解过程参数的概念,更不用说它根本没有过程的概念。让我们看一下 f_set_data_pointer 过程的实现:
f_set_data_pointer:
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push eax
lea eax, [esp + 8]
push dword [eax]
pop dword [data_pointer]
pop eax
ret
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL)
push rax
lea rax, [data_pointer]
mov [rax], rcx
pop rax
ret
else if (ACTIVE_TARGET = TARGET_L64_O)
push rax
lea rax, [data_pointer]
mov [rax], rdi
pop rax
ret
end if
这段代码也不复杂。传递给这个过程的参数只是被写入到 data_pointer 位置。
f_set_data_length
这个过程与 f_set_data_pointer 完全相同,唯一的区别是参数写入的地址。只需复制前面的代码,并将 data_pointer 更改为 data_length。
另一种选择是实现一个单一的过程,从而消除冗余代码,它将接受两个参数:
-
实际参数(无论是数据的指针还是其大小),因为汇编器并不关心类型
-
一个选择器,用于告诉过程参数值应存储的位置
尝试自己实现这个;这将是一个很好的快速练习。
GetPointers()
GetPointers() 过程是我们唯一公开的过程,只有这个过程对动态链接器或静态链接器可见,具体取决于选择的输出目标。这个过程的逻辑很原始。它创建一个结构体(在这个例子中,结构体是静态分配的),并用核心过程的地址填充它,最后返回这个结构体的地址:
GetPointers:
if (ACTIVE_TARGET = TARGET_W32_OBJ) |\
(ACTIVE_TARGET = TARGET_W32_DLL) |\
(ACTIVE_TARGET = TARGET_L32_O)
push dword pointers
pop eax
mov [eax], dword f_set_data_pointer
mov [eax + 4], dword f_set_data_length
mov [eax + 8], dword f_encrypt
mov [eax + 12], dword f_decrypt
ret
else if (ACTIVE_TARGET = TARGET_W64_OBJ) |\
(ACTIVE_TARGET = TARGET_W64_DLL) |\
(ACTIVE_TARGET = TARGET_L64_O)
push rbx
mov rbx, pointers
mov rax, rbx
mov rbx, f_set_data_pointer
mov [rax], rbx
mov rbx, f_set_data_length
mov [rax + 8], rbx
mov rbx, f_encrypt
mov [rax + 16], rbx
mov rbx, f_decrypt
mov [rax + 24], rbx
pop rbx
ret
end if
一旦所有前面的过程都添加到主源文件中,你可以安全地编译它,并看到所选择的输出格式的输出被生成。如果你在这里指定了目标,你应该能够看到一个 32 位的 Windows DLL 被创建。
与 C/C++ 接口
让我利用本章的主题说一说。够了,够了,汇编语言,我们做点 C 语言的东西(对于那些愿意将汇编代码与 C++ 链接的人,这个 C 示例应该很容易理解;如果不理解——那你拿错书了)。作为一个例子,我们将从我们的汇编源文件中生成一个目标文件,并将其与在 C 中编写的代码链接,目标平台包括 32 位和 64 位的 Windows 和 Linux。
静态链接 - Visual Studio 2017
首先,让我们看看如何生成目标文件。我相信你已经了解了如何生成不同目标,特别是如何为本例生成目标。我们从 32 位的 MSCOFF 目标文件开始,通过将ACTIVE_TARGET变量设置为TARGET_W32_OBJ并编译主源文件来实现。
在 Visual Studio 中创建一个 C/C++项目,并将目标文件复制到项目目录中,如以下截图所示(截图显示了 32 位和 64 位的目标文件):

如前面的截图所示,我们还需要至少一个文件,即头文件。由于我们的加密引擎相当简单,所以不需要复杂的头文件。这里显示的这个头文件就足够了:

上述代码中有一个小陷阱。在阅读下一个段落之前,尝试找出其中不正确的部分。
从技术上讲,代码是正确的。它会编译并运行没有问题,但在将汇编语言编写的模块与其他语言链接时,有一个非常重要且初看并不明显的方面:结构成员对齐。在这个例子中,我们只使用了一个结构(用于存储过程指针),并且我们小心地处理了它,以确保指针根据平台正确对齐。虽然我们在字节边界上对数据进行了对齐(顺序存储),但 Visual Studio 的默认结构成员对齐值是“默认”,这个值并没有提供太多信息。我们可以做出假设(在这种情况下,我们可以假设“默认”意味着第一种选项,即 1 字节对齐),但这并没有保证,我们必须明确指定对齐方式,因为假设不仅在汇编语言中并不总是有效,而且还会带来严重的风险。需要提到的是,尽管我们在这一段中提到了 Visual Studio,但同样的情况适用于任何 C 编译器。
指定结构成员对齐的一种方式是通过项目设置,如下所示:

对于我们的例子来说,这已经足够了,但在更大的项目中可能会导致问题。强烈建议在没有合理需求的情况下不要改变整个项目的结构成员对齐方式。相反,我们可以对我们的头文件做一个小修改,告诉编译器如何处理这个特定结构的成员对齐。在crypto_functions_t结构声明之前插入以下代码:
#ifdef WIN32 *// For Windows platforms (MSVC)*
#pragma pack(push, 1) *// set structure member alignment to 1*
#define PACKED
#else *// Do the same for Unix based platforms* (GCC)
#define PACKED __attribute__((packed, aligned(1)))
#endif
在声明之后插入以下内容:
#ifdef WIN32 *// For Windows platforms*
#pragma pack(pop) *// Restore previous alignment settings*
#endif
现在,考虑以下这一行:
}crypto_functions_t, *pcrypto_functions_t;
将前一行更改为:
}PACKED crypto_functions_t, *pcrypto_functions_t;
然后,按照以下截图所示,添加main.c文件:

main.c文件中的代码不言自明。这里只有两个局部变量;testString变量代表我们要处理的数据,funcs将存储指向我们加密核心中pointers结构的指针。
不要急着构建项目,因为我们还没有告诉 Visual Studio 关于我们的目标文件。右键点击项目,选择“属性”。以下截图展示了如何为 64 位平台项目添加我们的目标文件。32 位项目也应该做同样的操作,只是需要注意将哪个目标文件分配给哪个平台:

在附带的示例项目中,crypto_w64.obj文件用于 x64 平台,crypto_w32.obj则用于 x86 平台。
你现在可以自由地构建和运行项目(无论是 x86 还是 x64,只要目标文件正确指定)。我建议你在main.c文件的第 13 行和第 15 行设置断点,以便能够观察到testString所指向内存的变化。运行时,你会看到类似于以下的内容(之所以说“类似”,是因为每次构建加密核心时,密钥都会不同):

上一截图展示了加密前传入核心的数据。接下来的截图则展示了相同的数据,在加密后状态:

解密这些加密数据将会让我们回到那个熟悉的Hello, World!。
静态链接 - GCC
在将汇编源代码编译为目标文件并链接到高级语言代码时,Visual Studio 和 GCC 之间并没有太大区别。实际上,坦率地说,我们必须承认,从汇编代码编译出来的目标文件与从高级语言编译出来的目标文件并没有什么不同。对于 GCC 来说,我们有高级语言源代码(C 源代码和头文件,文件无需修改)和两个目标文件,为了方便起见,我们将其命名为crypto_32.o和crypto_64.o。用于构建可执行文件的命令会略有不同,具体取决于所使用的平台。如果你正在运行 32 位 Linux 系统,则需要执行以下命令,分别构建 32 位和 64 位的可执行文件:
gcc -o test32 main.c crypto_32.o gcc -o test64 main.c crypto_64.o -m64
第二个命令只有在你安装了 64 位开发工具/库时才能工作。
如果你正在运行 64 位系统,则需要对命令进行轻微修改(并确保安装了 32 位开发工具和库):
gcc -o test32 main.c crypto_32.o -m32
以及:
gcc -o test64 main.c crypto_64.o
在使用 GDB 检查内存内容时,当运行其中一个testxx文件时,你将看到类似于以下截图的内容,这是加密前的状态:

加密后,你将看到类似于以下内容:

动态链接
动态链接意味着使用动态链接库(在 Windows 上)或共享对象(在 Linux 上),其原理与其他 DLL/SO 相同。动态链接的机制将在下一章简要介绍。
然而,我们现在需要构建动态链接库和共享对象,以便能够继续进行。编译 crypto.asm 文件时,将 ACTIVE_TARGET 编译时变量设置为 TARGET_W32_DLL,以生成 Windows 的 32 位 DLL,然后设置为 TARGET_W64_DLL,以生成 64 位 DLL。请注意,改变 ACTIVE_TARGET 不会影响输出文件的名称,因此我们需要相应地重命名每次编译的结果。
在 Windows 上,你只需改变 ACTIVE_TARGET 编译时变量,并通过 GUI 中的“运行 | 编译”选项进行编译(或按 Ctrl + F9 快捷键),而在 Linux 上,你需要先构建目标文件,然后在终端中输入另一个命令。该命令将是以下之一:
*# For 64-bit output on 64-bit machine*
gcc -shared crypto_64.o -o libcrypto_64.so
*# For 64-bit output on 32-bit machine*
gcc -shared crypto_64.o -o libcrypto_64.so -m64
*# For 32-bit output on 64-bit machine*
gcc -shared crypto_32.o -o libcrypto_32.so -m32
*# For 32-bit output on 32-bit machine*
gcc -shared crypto_32.o -o libcrypto_32.so
现在我们有了 Windows 的 DLL 和 Linux 的共享对象,可以继续进行,看看如何将用汇编编写的模块与 .NET 等框架进行集成。
汇编语言与托管代码
正如我们之前看到的那样,静态或动态链接并不像看起来那样困难,只要我们处理的是本地代码。但当我们决定将用汇编语言编写的代码与用 C# 编写的程序(它是一个托管环境,并不是由处理器直接运行,而是由某种虚拟机运行)结合时,会发生什么呢?许多人害怕混合本地模块和托管模块。将由汇编源代码编译的本地模块与托管代码结合,似乎甚至更可怕或不可能。然而,正如我们之前所见,在二进制层面,最初用汇编语言编写的模块与其他语言编写的模块之间没有区别。当涉及到像 C# 这样的托管代码时,事情变得比链接本地对象文件或使用 DLL/SO 稍微复杂一些。以下内容不适用于托管 C++ 代码,在这种情况下,你可以简单地按照本章前面讨论的步骤,将本地对象与托管代码链接,因为托管 C++ 是 Visual Studio 唯一支持的可以提供这种功能的语言。
然而,对于 C# 来说,我们只能使用 DLL/SO,因为 C# 是一个纯托管环境,无法处理以对象文件形式存在的本地代码。在这种情况下,我们需要一种适配器代码。在我们的示例中,我们将使用一个简单的类,它从 Windows 上的 crypto_wxx.dll 或 Linux 上的 libcrypto_xx.so 导入核心功能,并通过其方法将这些功能暴露给代码的其他部分。
有一种普遍的误解认为 .NET 平台仅限于 Windows。遗憾的是,这种误解相当普遍。然而,实际上,.NET 平台几乎像 Java 一样具有良好的可移植性,并支持多种平台。不过,我们将重点讨论 Windows(32/64 位)和 Linux(32/64 位)。
本地结构与托管结构
当我们尝试将类似于我们核心接口实现的东西与 .NET 等平台结合使用时,首先会遇到的问题是如何在托管代码和本地代码之间传递数据。托管代码和本地代码几乎不可能访问相同的内存区域。这不代表不可能,但绝对不健康,因此我们必须在这两个领域之间传递数据——托管领域和本地领域。幸运的是,.NET 框架中有一个类允许我们相对轻松地执行此类操作——System.Runtime.InteropServices.Marshal。由于我们使用的是一个指向包含指向导出过程的指针的结构的指针,因此我们需要实现一个托管结构,用于与我们的 .NET 加密类一起使用,这可以通过一种相当简单的方式完成:
*// First of all, we tell the compiler how members of the*
*//struct are stored in memory and alignment thereof*
[StructLayout(LayoutKind.Sequential, Pack=1)]
*// Then we implement the structure itself*
internal struct Funcs
{
internal IntPtr f_set_data_pointer;
internal IntPtr f_set_data_length;
internal IntPtr f_encrypt;
internal IntPtr f_decrypt;
}
前面的代码完美地声明了我们需要的结构类型,我们可以开始实现加密类。尽管 C# 类的实现远远超出了本书的范围,但在这种情况下,似乎适合用几行代码定义方法和委托。
从 DLL/SO 导入和函数指针
.NET 中的互操作性是一个有趣的话题,但最好参考专门讨论它的资源。在这里,我们只考虑 .NET 中的函数指针的类比以及动态导入 DLL 和共享对象导出函数的误解。但首先,让我们构建类,导入 GetPointers() 过程,并定义函数指针委托:
internal class Crypto
{
Funcs functions;
IntPtr buffer;
byte[] data;
*// The following two lines make up the properties of the class*
internal byte[] Data { get { return data; } }
internal int Length { get { return data.Length; } }
*// Declare binding for GetPointers()*
*// The following line is written for 64-bit targets, you should*
*// change the file name to crypto_32.so when building for*
*// 32-bit systems.
// Change the name to crypto_wXX.dll when on Windows, where XX*
*// stands for 32 or 64.*
[DllImport("crypto_64.so", CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr GetPointers();
*// Declare delegates (our function pointers)*
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dSetDataPointer(IntPtr p);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dSetDataSize(int s);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dEncrypt();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void dDecrypt();
*// Constructor*
internal Crypto()
{
*// Luckily when we get a pointer to structure by calling*
*// GetPointers() we do not have to do more than just let*
*// the framework convert native structure to managed one*
functions = (Funcs)Marshal.PtrToStructure(
GetPointers(),
typeof(Funcs));
*// Set initial buffer ptr*
buffer = IntPtr.Zero;
}
*// SetDataPointer() method is the most complex one in our class,*
*// as it includes invocation of SetDataLength()*
internal void SetDataPointer(byte[] p)
{
*// If an unmanaged buffer has been previously allocated,*
*// then we need to free it first.*
if(IntPtr.Zero != buffer)
Marshal.FreeHGlobal(buffer);
buffer = Marshal.AllocHGlobal(p.Length);
*// Copy data to both the local storage and unmanaged buffer*
data = new byte[p.Length];
Array.Copy(p, data, p.Length);
Marshal.Copy(p, 0, buffer, p.Length);
*// Call f_set_data_pointer with a pointer to unmanaged buffer*
((dSetDataPointer) Marshal.GetDelegateFromFunctionPointer(
functions.f_set_data_pointer,
typeof(dSetDataPointer)))(buffer);
*// Tell the core what the length of the data buffer is*
((dSetDataSize) Marshal.GetDelegateFromFunctionPointer(
functions.f_set_data_length,
typeof(dSetDataSize)))(p.Length);
}
*// The remaining two methods are more than simple*
internal void Encrypt()
{
// Encrypt the data in the unmanaged buffer and copy it
// to local storage
((dEncrypt)Marshal.GetDelegateFromFunctionPointer(
functions.f_encrypt,
typeof(dEncrypt)))();
Marshal.Copy(buffer, data, 0, data.Length);
}
internal void Decrypt()
{
// Decrypt the data in the unmanaged buffer and copy it
// to local storage
((dDecrypt)Marshal.GetDelegateFromFunctionPointer(
functions.f_decrypt,
typeof(dDecrypt)))();
Marshal.Copy(buffer, data, 0, data.Length);
}
}
前面的代码适用于 Linux 版本;然而,通过将共享对象的名称更改为 DLL 的名称,它可以很容易地转换为 Windows 版本。使用这个类,操作我们的 Crypto Core 变得相当简单,可以通过以下代码总结:
Crypto c = new Crypto();
string message = "This program uses \"Crypto Engine\" written in Assembly language.";
c.SetDataPointer(ASCIIEncoding.ASCII.GetBytes(message);
c.Encrypt();
c.Decrypt();
然而,尽管如果我们实现前面的类并尝试在代码中使用它,它会顺利编译,但我们仍然无法实际运行它。这是因为我们需要根据所选平台提供 DLL 或共享对象。提供库的最简单方法是将它们复制到解决方案文件夹中,并告诉 IDE(Visual Studio 或 Monodevelop)正确处理它们。
第一步是将库(Windows 上的 DLL 和 Linux 上的 SO)复制到项目文件夹中。下图显示了 Linux 上的 Monodevelop 项目文件夹,但对于 Linux 和 Windows,过程完全相同:

下一步是告诉 IDE 如何处理这些文件。首先,右键点击项目,选择“添加 | 现有项”(Visual Studio)或“添加 | 添加文件”(Monodevelop),然后设置每个库的属性,如下图所示。
在 Visual Studio 中设置属性:

在 Monodevelop 中设置属性:

虽然图形界面不同,但两者都需要将构建操作设置为 Content,并在 Visual Studio 中将“复制到输出目录”设置为“始终复制”,在 Monodevelop 中勾选该选项。
现在我们可以构建项目(无论是在 Windows 还是 Linux 上)并运行它。我们可以观察内存中加密/解密的数据,或者添加一个小函数,打印出特定范围内的内存内容。
如果一切设置正确,那么在 Windows 上的输出应类似于以下内容:

Linux 上的输出将类似于以下内容:

总结
本章中,我们仅介绍了将程序集代码与外部世界进行接口的几个方面。目前有许多编程语言,但我们决定集中讲解 C/C++ 和 .NET 平台,因为它们是最能展示如何将用汇编语言编写的模块与用高级语言编写的代码进行绑定的方式。简单来说,任何编译为本地代码的语言都会使用与 C 和 C++ 相同的机制;另一方面,任何像 .NET 这样的平台,尽管有特定平台的绑定机制,但在低层次上会使用相同的方式。
不过,我想有一个问题仍然悬而未决,那就是如何将第三方代码链接到我们的程序集程序中?尽管本章的标题可能暗示这个话题已经包括在内,但将其放在下一章讨论会更有意义,因为我们将讨论的唯一内容就是如何在用汇编语言编写的程序中使用第三方代码。
第九章:操作系统接口
在准备写这章内容时,我想起了大学时上的一门实时系统课程。当然不是整个课程,而是我们被分配的一个任务——如果说最有趣的任务之一,那可能就是这个了,甚至可以说是最有趣的任务。我们必须写一个小程序,使得文本字符串在屏幕上从左到右再返回,直到按下某个键盘上的按键为止。另外两个按键可以控制字符串移动的速度。那是 2001 年或 2002 年,我们仍然使用 DOS 来进行与汇编相关的练习,而这个任务看起来相当简单。
我个人觉得使用 DOS 中断来做这个事情非常无聊(当时我还不知道奥卡姆剃刀原则,此外,我还想显得聪明),于是我决定完全不使用任何操作系统。我的笔记本有一个软盘驱动器,所以我唯一缺少的是一个能够将原始扇区写入软盘的程序,而我自己写了这个程序(猜猜我用的是什么编程语言)。
这个程序由两部分组成:
-
引导加载程序:这是一个小程序(编译后必须适应一个 512 字节的扇区),只负责一件事——从软盘加载我的程序并设置它以便运行
-
程序:这实际上是一个用于显示移动字符串的程序
拥有适当的文档并不是实现整个程序包的难点。然而,我不得不处理一些通常我们不太接触的事情。其中之一就是一个原始的显卡驱动程序,它负责切换到图形模式,正确位置显示字符串,并在程序终止之前切换回文本模式。另一个则是编写一个原始的键盘驱动程序,基本上是一个中断处理程序,用来监听键盘输入,调整字符串的移动速度,或者指示程序终止。简单来说,我必须自己处理硬件接口(哦,那是“实模式”的好时光……一切既简单又复杂)。
在现代,除非你是驱动程序开发者,否则你完全不需要直接访问硬件——操作系统会为我们处理所有的脏活,我们可以专注于实现自己的想法。
到目前为止,我们能够用汇编语言实现任何算法,甚至在拥有适当文档的情况下,我们可以编写自己的驱动程序,但这么做会在编写用户空间应用程序时引入冗余工作。更不用说,硬件供应商已经提供了所有硬件的驱动程序,而奥卡姆剃刀原则告诉我们不要无谓地增加复杂性。现代操作系统擅长管理这些驱动程序并提供更简单、无缝的硬件访问,使我们能够专注于创造的过程。
本章中,我们将看到如何轻松无痛地使用操作系统和其他人已创建的众多库所赋予我们的功能。我们将首先将第三方目标文件链接到我们的代码,接着通过导入 DLL/SO 的 API,最后通过动态加载 DLL/SO,在运行时导入 API。
环的概念
几乎所有现代平台(除了少数嵌入式平台)都采用相同的安全原则——通过安全级别和权限划分执行环境;在这种情况下,这意味着能够访问某些资源。在基于英特尔的平台上,有四个安全级别,称为保护环。这些环的编号从 0 到 3,数字越大,权限越低。显然,在权限较低的级别运行的代码不能直接访问具有更高权限级别的内存。我们将很快看到数据是如何在不同权限级别之间传输的。
以下图示说明保护环的概念:

下面是保护环的不同权限描述:
-
Ring 0 是权限最高的级别,所有指令都可以使用,所有硬件都可以访问。这里是内核所在的灰色区域,伴随内核空间的驱动程序。
-
Ring 1 和 Ring 2 主要用于作为驱动程序执行环境,但几乎没有被使用。
-
Ring 3 是用户空间。这是普通软件被授予的权限级别,也是我们唯一关注的权限级别。虽然深入了解可能会非常有趣,但对于本书的目的来说,这并不实用,因为我们所有的代码只需要权限级别 3。
系统调用
如果用户空间的应用程序无法向内核发起服务请求,那么它毫无价值,因为它甚至无法在不请求内核终止它正在运行的进程的情况下正常终止。所有系统调用可以按如下方式分类:
-
进程控制:属于此类别的系统调用负责进程/线程的创建和管理,以及内存分配/释放。
-
文件管理:这些系统调用负责文件的创建、删除和 IO。
-
设备管理:此类别包含用于设备管理/访问的系统调用。
-
维护:此类别包含用于管理日期、时间、文件或设备属性的系统调用。
-
通信:管理通信通道和远程设备
系统调用硬件接口
在硬件级别,处理器为我们提供了几种方式来调用内核过程以处理系统调用:
-
通过中断 (32 位系统上的 INT 指令):操作系统为具有特定编号的中断分配描述符,指向内核空间中的一个过程,该过程根据中断的参数处理该中断(参数通过寄存器传递)。其中一个参数是指向系统调用表的索引(粗略地说,是指向特定系统调用处理程序的指针表)。
-
使用 SYSENTER 指令 (32 位系统,不包括 WOW64 进程):从 Pentium II 开始,我们可以使用
SYSENTER指令快速调用 ring 0 程序。该指令伴随有SYSEXIT指令,用于从系统调用返回。 -
使用 SYSCALL 指令 (64 位系统):此指令由 x86_64 架构引入,仅在长模式下可用。该指令允许更快速地转移到系统调用处理程序,并且不会访问中断描述符表。
直接系统调用
使用前述指令之一将意味着进行直接的系统调用,并绕过所有的系统库,如下图所示。然而,这不是最佳实践,我们稍后会看到为什么:

在 Linux 上使用直接系统调用方法很可能会成功,因为 Linux 系统调用有良好的文档支持,并且其调用号是众所周知的(可以在 32 位系统的 /usr/include/asm/unistd_32.h 和 64 位系统的 /usr/include/asm/unistd_64.h 中找到),而且这些调用号一般不会发生变化。例如,以下代码在 32 位 Linux 系统上将一个 msg 字符串打印到标准输出:
mov eax, 4 *; 4 is the number of sys_write system call*
mov ebx, 1 *; 1 is the stdout file number*
lea ecx, [msg] *; msg is the label (address) of the string to write*
mov edx, len *; length of msg in bytes*
int 0x80 *; make syscall*
这是其 64 位版本:
mov rax, 1 *; 1 is the number of sys_write on 64-bit Linux*
mov rdi, 1 *; 1 is the stdout file number*
mov rsi, msg *; msg is the label (address) of the string*
mov rdx, len *; length of msg in bytes*
syscall *; make the system call*
然而,在 Windows 上,尽管思想相同,但实现方式却不同。首先,Windows 系统调用没有公开的官方文档,这不仅需要一定的反向工程技术,还无法保证通过反向工程 ntdll.dll 获得的信息在下次更新后仍然保持不变,更不用说系统调用号会在不同版本之间发生变化。然而,为了普及教育,下面是 32 位 ntdll.dll 的系统调用调用过程:
_KiIntSystemCall:
lea edx, [esp + 8] *; Load EDX with pointer to the parameter block*
int 0x2e *; make system call*
此外,如果 SYSENTER 指令可用,那么我们将得到以下情况:
_KiFastSystemCall:
mov edx, esp *; Load EDX with stack pointer so that kernel*
*; may access the parameter block on stack*
sysenter *; make system call*
尽管第二种变体更具潜力,但仍无法保证参数块的格式不会发生变化(尽管这种情况不太可能)。总结本小节,值得一提的是,除非绝对必要,否则强烈不建议使用直接系统调用。
间接系统调用
更常见的利用系统服务的方式是通过支持库,无论是 Windows 上的系统 DLL 还是 Linux 上的 libc,它们提供了比原始系统调用接口更便捷的 API。过程如下面的图所示:

尽管看起来引入另一层可能会带来冗余的复杂性,但实际上情况正好相反,更不用说在这种情况下我们的代码将变得更具可移植性。
使用库
正如之前所述,从汇编语言编写的程序与操作系统交互的最佳方式是通过系统 API —— Windows 上的系统 DLL 和 Linux 上的 libc,本章的其余部分将专注于此主题,因为它将大大简化你作为汇编开发者的工作。
本章的其余部分将专注于如何使用外部库和 DLL 文件(如果在 Windows 上),或者在 Linux 上使用外部库和共享对象。我们将一举两得,不仅学习如何将 DLL 或系统库文件链接到代码中,还将涵盖如何将其他对象文件与代码链接。
为了举例说明,我们将创建一个小程序,该程序将消息打印到标准输出,并使用我们在第八章中开发的模块,混合汇编语言编写的模块和高级语言编写的模块,用于消息的加密和解密。
Windows
在 Windows 上,有两种方法可以访问外部功能:一种是将代码编译为对象文件,并将其与其他对象文件或库链接,另一种是创建可执行文件并导入由不同 DLL 导出的函数。我们将分别研究这两种方法,以便你能在需要时选择最合适的方法。
与对象文件和/或库文件的链接
对象文件是一个包含可执行代码和/或数据的文件。它不能单独执行(即使它包含了可执行文件的所有代码),因为存储在此类文件中的信息仅供链接器在构建最终可执行文件时使用;否则,文件中的所有代码和数据都没有绑定到任何地址,仅提供提示。关于 Microsoft 对象文件格式的详细信息以及 PE 可执行格式的规格,可以在www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx获取。访问此 URL,点击下载按钮,选择pecoff.docx。
对象文件
让我们开始写代码,创建我们的对象文件 obj_win.asm:
*; First we need to tell the assembler that*
*; we expect an object file compatible with MS linker*
format MS COFF
*; Then specify the external API we need*
*; extrn means that the procedure is not in this file*
*; the 4 after the '@' specifies size of procedure parameters*
*; in bytes*
*; ExitProcess is a label and dword is its size*
extrn '__imp__ExitProcess@4' as ExitProcess:dword
extrn '__imp__GetStdHandle@4' as GetStdHandle:dword
extrn '__imp__WriteConsoleA@20' as WriteConsole:dword
*; And, of course, our "crypto API"*
extrn '_GetPointers' as GetPointers:dword
*; Define a constant for GetStdHandle()*
STD_OUTPUT_HANDLE equal -11
*; and a structure to ease the access to "crypto functions"*
struc crypto_functions
{
.f_set_data_pointer dd ?
.f_set_data_length dd ?
.f_encrypt dd ?
.f_decrypt dd ?
}
*; The following structure makes it a bit easier*
*; to manipulate strings and sizes thereof*
struc string [s]
{
common
. db s
.length = $ - .
}
在我们实现代码之前,让我们先创建数据段,以便代码更容易理解:
section '.data' data readable writeable
*; We will store the STDOUT handle here*
stdout dd ?
*; This buffer contains the message we will operate on*
buffer string 'Hello from object file!', 0x0a, 0x0d
*; Progress messages*
msg1 string 'Encrypted', 0x0a, 0x0d
msg2 string 'Decrypted', 0x0a, 0x0d
*; This one is required by the WriteConsole procedure*
bytesWritten dd ?
数据段非常自明,我们现在终于可以写代码了:
section '.text' code readable executable
*; We need the entry point to be accessible to the linker,*
*; therefore we make it "public"*
public _start
_start:
*; The first step would be obtaining the STDOUT handle*
push STD_OUTPUT_HANDLE
*; Since we are linking against a DLL, the GetStdHandle*
*; label would refer to a location in the import section*
*; which the linker will create for us. Hence we make an*
*; indirect call*
call [GetStdHandle]
*; Store the handle*
mov [stdout], eax
*; Print the message*
push 0 bytesWritten buffer.length buffer eax
call [WriteConsole]
*; Let's play with encryption a bit*
*; First get the procedure pointers. Since the GetPointers()*
*; is in another object file, it would be statically linked,*
*; therefore we make a direct call*
call GetPointers
*; Store the pointer to the crypto_functions structure in EBX*
mov ebx, eax
记得virtual指令吗?
我们程序员有时是懒惰的,喜欢事物变得更加方便,尤其是在编写代码一周后查看自己写的代码时。因此,我们更愿意按名称处理我们的加密程序,而不是根据crypto_functions结构的地址偏移来处理。这时,virtual指令派上用场,它允许我们像下面的代码片段所示那样将由 EBX 寄存器指向的位置标记为虚拟标签:
virtual at ebx
funcs crypto_functions
end virtual
funcs是一个虚拟标签,指向由ebx寄存器指向的位置,它将在编译时被替换为ebx。任何通过funcs引用的crypto_functions结构的成员都将被其在结构中的偏移量所替换。现在,让我们设置加密引擎,对存储在buffer中的消息进行加密和解密:
*; Set the pointer to data and its length*
push buffer
call [funcs.f_set_data_pointer] *; Equivalent to 'call [ebx]'*
push buffer.length
call [funcs.f_set_data_length]
*; We have to restore the stack pointer due to the*
*; fact that the above two procedures are in accordance*
*; with the cdecl calling convention*
add esp, 8
*; Encrypt the content of the buffer*
call [funcs.f_encrypt]
*; Print progress message*
push 0 bytesWritten msg1.length msg1 [stdout]
call [WriteConsole]
*; Decrypt the content of the buffer*
call [funcs.f_decrypt]
*; Print another progress message*
push 0 bytesWritten msg2.length msg2 [stdout]
call [WriteConsole]
*; Print the content of the buffer in order to verify*
*; decryption*
push 0 bytesWritten buffer.length buffer [stdout]
call [WriteConsole]
*; All is fine and we are free to exit*
push 0
call [ExitProcess]
生成可执行文件
编译这个源文件会生成obj_win.obj文件,我们将把它链接到kernel32.lib和crypto_w32.obj。但是我们该去哪里找到kernel32.lib文件呢?这个任务有时可能并不简单,尽管它并不困难。所有的系统库都可以在c:\Program Files\Microsoft SDKs\Windows\vX.X\Lib目录中找到,其中vX.X代表版本号(很可能会有多个版本)。对于 64 位 Windows,目录应为c:\Program Files (x86)\Microsoft SDKs\Windows\vX.X\Lib。所以,让我们把crypto_w32.obj文件复制到工作目录中,然后尝试链接它。打开 VS 2017 的开发者命令提示符窗口,如下图所示,并导航到你的工作目录:

输入以下命令:
link /entry:start /subsystem:console obj_win.obj "c:\Program Files\Microsoft SDKs\Windows\v7.0A\Lib\kernel32.lib" crypto_w32.obj
一旦按下回车键,如果一切顺利,控制台中将显示 Microsoft (R)增量链接器的徽标消息,随后会出现新的提示符,并且会生成obj_win.exe文件。尝试运行它,应该会得到以下输出:
Hello from object file!
Encrypted
Decrypted
Hello from object file!
Voilà!我们刚刚在汇编代码中使用了外部功能。
从 DLL 导入过程
Flat Assembler 为我们提供了另一种使用外部功能的方法。虽然在其他汇编器中我们需要一个链接器来链接 DLL 文件,Flat Assembler 使得我们可以生成一个包含所有在源代码中定义的导入项的可执行文件,这样我们只需编译源代码并运行可执行文件。
运行时将动态链接库链接到我们代码的过程相当简单,可以通过以下图示来说明:

一旦加载器加载了一个可执行文件,它将解析其导入表(如果存在),并识别请求的库(有关导入表格式规格,请参阅PECOFF.docx)。对于在导入部分找到的每个库引用,加载器尝试加载该库,然后解析可执行文件的导入部分,查找该库所导出的过程名称,并扫描库的导出部分以寻找匹配项。一旦找到匹配项,加载器计算该条目的虚拟地址,并将其写回可执行文件的导入部分。此过程对于每个请求的库的每个导入条目都会重复。
对于我们的例子,我们将使用与链接对象相同的代码(只需将其重命名为dll_win.asm),并做一些微小的修改,将crypto_w32.obj替换为crypto_w32.dll。首先,删除所有的extrn和public声明,然后通过将format MS COFF更改为format PE CONSOLE,告诉汇编器这次我们期待的是一个控制台可执行文件,而不是一个对象文件。
由于我们将创建自己的导入表,因此需要包含win32a.inc文件,该文件包含我们可能需要的所有宏。将这一行添加到格式声明之后:
include 'win32a.inc'
我们快完成了;将以下代码附加到源文件中:
section '.idata' import data readable writeable
*; Tell the assembler which libraries we are interested in*
library kernel,'kernel32.dll',\
crypto,'crypto_w32.dll'
*; Specify procedures we need from kernel32.dll*
import kernel,\
GetStdHandle, 'GetStdHandle',\
WriteConsole, 'WriteConsoleA',\
ExitProcess, 'ExitProcess'
*; And, finally, tell the assembler we are also*
*; interested in our crypto engine*
import crypto,\
GetPointers, 'GetPointers'
我们需要做的最后一项修改是将call GetPointers改为call [GetPointers],因为这次GetPointers过程不会静态链接到我们的可执行文件中,而是将从动态链接库中导入,这意味着GetPointers标签将引用内存中的一个地址,该地址存储着GetPointers过程的地址。
尝试编译该文件并在控制台中运行。你应该得到与我们从多个对象链接的可执行文件相同的输出。
如果你遇到一个错误消息,显示可执行文件未启动,而不是预期的输出,尝试在platform.inc文件的TARGET_W32_DLL部分中添加section '.reloc' fixups data readable discardable这一行,然后重新编译crypto_w32.dll。这对于构建 DLL 是正确的,尽管在某些情况下没有这个也可能能正常工作。
当然,可以使用LoadLibrary() Windows API 手动加载 DLL,并通过GetProcAddress()解析所需过程的地址,但这与链接 DLL 或导入 API 没有区别,因为我们仍然需要导入这两个 API。然而,有一种方法可以让我们以所谓的隐形方式导入 API 地址。
在构建 64 位可执行文件时,完全适用相同的规则。唯一的区别是kernel32.lib的位置,它将位于c:\Program Files\Microsoft SDKs\Windows\vX.X\Lib\x64,以及指针的大小。另外,非常重要的一点是,记住在 x86_64 Windows 上使用的调用约定既不是cdecl也不是stdcall!
Linux
在 Linux 中,就像在 Windows 中一样,我们支持静态和动态链接(也支持手动导入)。主要区别在于,在 Linux 中(这是我个人的看法),构建软件要容易得多,因为所有开发工具都集成在系统中。嗯,除了 Flat Assembler,但它的集成并不成问题——我们只需将 fasm 可执行文件复制到用户 PATH 环境变量中包含的某个 bin 目录即可。
幸运的是,Flat Assembler 内置支持生成目标文件和可执行文件,它能够像在 Windows 上一样在 Linux 上导入库中的程序。稍后我们将看到,在 Linux 上这些方法与 Windows 上几乎相同,只要我们不深入研究 ELF 规范和格式的细节。
如果你想深入探索 ELF 格式,相关规范可以在以下链接查看:
refspecs.linuxbase.org/elf/elf.pdf 了解 32 位 ELF 规范。
和
ftp.openwatcom.org/devel/docs/elf-64-gen.pdf 了解 64 位 ELF 规范。
如果这些链接出现失效,你也可以通过 Google 或其他搜索引擎找到这些规范。
就像我们在 Windows 中做的那样,我们将首先将几个目标文件链接到一个可执行文件中,然后创建一个带有动态依赖链接的可执行 ELF 文件。
链接目标文件和/或库文件
Microsoft 公共对象文件格式(MS COFF)和 ELF(可执行与可链接格式,以前称为 可扩展链接格式)的结构差异很大,但对我们来说,这种差异完全不重要。ELF 由 UNIX 系统实验室开发,并于 1997 年发布。它后来被选为 32 位 Intel 架构的便携式目标文件格式。直到今天,32 位系统使用 ELF,64 位系统使用 ELF64。
然而,从我们的角度来看,Linux 的代码与 Windows 的代码非常相似。更准确地说,正是 FASM 让它们非常相似。
目标文件
就像 Windows 的目标文件源代码一样,我们一如既往地首先告诉汇编器我们期望什么样的输出,哪些程序是公开的,哪些是外部的:
format ELF
*; As we want GCC to take care of all the startup code*
*; we will call our procedure "main" instead of _start,*
*; otherwise we would be using LD instead of GCC and*
*; would have to specify all runtime libraries manually.*
public main
*; The following function is linked from libc*
extrn printf
*; And this one is from our crypto library*
extrn GetPointers
接下来我们进行便捷宏定义,建议将便捷宏放入一个单独的包含文件中,这样可以方便地在不同代码中使用,而无需重新编写它们:
struc crypto_functions
{
.f_set_data_pointer dd ?
.f_set_data_length dd ?
.f_encrypt dd ?
.f_decrypt dd ?
}
struc string [s]
{
common
. db s
.length = $ - .
.terminator db 0
}
数据段几乎与 Windows 目标文件中的相同,不同之处在于我们不需要一个变量来保存 stdout 句柄:
section '.data' writeable
buffer string 'Hello from ELF linked from objects!', 0x0a
msg1 string 'Encrypted', 0x0a
msg2 string 'Decrypted', 0x0a
最后是代码部分。从逻辑上来说,这段代码和之前一样,唯一的不同是使用了printf()代替WriteConsoleA(),在这种情况下,libc中的printf()实现将为我们处理所有的安排,并调用一个SYS_write的 Linux 系统调用。由于从 GCC 的角度看,我们仅仅是实现了main()函数,因此我们不需要自己终止进程,也就没有引入exit()过程——运行时代码会自动添加并链接,GCC 会处理其余的部分,而我们只需要从main()函数返回:
section '.text' executable
*; Remember that we are using GCC for linking, hence the name is*
*; main, rather than _start*
main:
*; Print the content of the buffer to stdout*
*; As all procedures (except crypto procedures) would be*
*; statically linked, we are using direct calls*
push buffer
call printf
*; Restore stack as printf() is a cdecl function*
add esp, 4
*; Get pointers to cryptographic procedures*
call GetPointers
mov ebx, eax
*; We will use the same trick to ease our access to cryptography*
*; procedures by defining a virtual structure*
virtual at ebx
funds crypto_functions
end virtual
*; Right now we will push parameters for all subsequent procedure*
*; calls onto the stack in reverse order (parameter for the last*
*; call is pushed first*
push 0 buffer msg2 msg1 buffer.length buffer
*; Set crypto library's data pointer*
*; Crypto procedures are not available at link time, hence not*
*; statically linked. Instead we obtain pointers thereof and this*
*; is the reason for indirect call*
call [funcs.f_set_data_pointer]
*; Restore stack*
add esp, 4
*; Set size of the data buffer*
call [funcs.f_set_data_length]
add esp, 4
*; Encrypt the buffer. As this procedure has no parameter, there*
*; is no reason to do anything to stack following this call*
call [funcs.f_encrypt]
*; Print msg1*
call printf
add esp, 4
*; Decrypt the buffer back*
call [funcs.f_decrypt]
*; Print msg2*
call printf
add esp, 4
*; Print the content of the buffer to ensure correct decryption*
call printf
add esp, 4
*; All is done, so we may safely exit*
pop eax
ret
生成可执行文件
将文件保存为o_lin.asm,并使用终端中的fasm o_lin.asm命令将其编译为目标文件。下一步将使用以下命令将o_lin.o与crypto_32.o链接:
gcc -o o_lin o_lin.o crypto_32.o
***# If you are on a 64-bit system then***
gcc -o o_lin o_lin.o crypto_32.o -m32
这将生成一个5KB o_lin可执行文件——相比我们曾经生成的代码大小,这个文件相当庞大。如此巨大的体积是由于 GCC 将 C 运行时库链接其中。尝试运行它,你应该在终端看到以下内容:

ELF 的动态链接
并不是总是适合将目标文件静态链接成单一可执行文件,Linux 提供了一个机制,可以生成一个 ELF 可执行文件,该文件在运行时与所需的库(共享对象)动态链接。Flat Assembler 曾经对 ELF 的支持相对基础,这意味着只能创建直接使用系统调用的可执行文件,或者创建一个目标文件以与其他文件链接(正如我们所做的那样)。
Flat Assembler 对 ELF 的支持在版本 1.69.05 发布时得到了扩展——添加了一些段属性,并引入了几个便捷宏,使我们能够手动在 ELF 可执行文件中创建导入表。这些宏位于 Linux 包中的examples/elfexe/dynamic目录下(在以下截图中有下划线标出):

这些宏可以在本章随附代码中的linux_include文件夹下找到。
代码
动态链接 ELF 的代码几乎与 ELF 目标文件的代码相同,只有一些微小的差别。首先,formatter指令必须告诉汇编器生成可执行文件,而不是目标文件:
format ELF executable 3 *; The 3 may be omitted if on Linux*
*; Include this in order to be able to create import section*
include 'linux_include/import32.inc'
*; We have to specify the entry point for the executable*
entry _start
本章中使用的便捷结构(crypto_functions 和 string)依然有效,并应被放置在文件中。虽然没有严格规定它们应放置的位置,但它们应当在使用之前出现:
*; The content of the data section is the same as in object file*
*; source. The section itself is declared in a different way (in*
*; fact, although, an ELF file is divided into sections, it is*
*; treated a bit differently when in memory - it is divided into*
*; segments)*
segment readable writeable
buffe string 'Hello from dynamically linked ELF!', 0x0a
msg1 string 'Encrypted', 0x0a
msg2 string 'Decrypted', 0x0a
为了增强 Flat Assembler 对 ELF 的支持,引入了一个新的段,其中一个是包含可与可执行文件一起使用的加载器名称的解释器:
segment interpreter writeable
db '/lib/ld-linux.so.2',0
另一个是动态的,作为一个导入索引。然而,我们不会自己声明这个段;相反,我们将使用两个宏——其中一个会创建所需库的列表,另一个则指定要导入的程序。在我们的例子中,它将如下所示:
*; In our example we only need to libraries - libc for*
*; printf() and exit() (and we will use exit() this time)*
*; and crypto_32.so for our cryptographic core.*
needed\
'libc-2.19.so',\
'crypto_32.so'
*; Then we specify requested procedures*
import\
printf,\
exit,\
GetPointers
其余的代码只需要做一些小的修改。首先,代码段声明如下:
segment executable readable
_start:
这次所有的程序都被间接调用:
push buffer
call [printf]
add esp, 4
call [GetPointers]
mov ebx, eax
virtual at ebx
funcs crypto_functions
end virtual
push 0 buffer msg2 msg1 buffer.length buffer
*; All procedures are cdecl, so we have to adjust*
*; the stack pointer upon return from procedures*
*; with parameters*
call [funcs.f_set_data_pointer]
add esp, 4
call [funcs.f_set_data_length]
add esp, 4
call [funcs.f_encrypt]
call [printf]
add esp, 4
call [funcs.f_decrypt]
call [printf]
add esp, 4
call [printf]
add esp, 4
call [exit]
我们替换掉的最后两条指令是:
pop eax
ret
和:
cal [exit]
将文件保存为so_lin.asm。
现在,你可以构建并运行新创建的可执行文件了:
fasm so_lin.asm
./so_lin
如果一切都做得正确,你应该看到这个:

总结
在这一章中,你了解了系统调用——操作系统的服务网关。你学到了,使用现有的库来间接调用系统调用,既更实用也更方便,而且更加安全。
本章故意没有提供 64 位的示例,因为我希望你能自己尝试编写这些简单的可执行文件的 64 位版本,作为一个小练习来测试自己。
现在我们是大师了。我们有了坚实的基础,能够用纯粹的 Intel 汇编实现任何算法,甚至能够直接调用系统调用(至少在 Linux 上可以,因为在 Windows 上这样做是强烈不推荐的)。然而,作为真正的大师,我们知道还有更多需要学习和探索的东西,因为单单一个基础是远远不够的。
第十章:修补遗留代码
几年前,我有机会参与一个有趣的项目——我接到了一个商家老板的电话,他因一个可悲的开发者锁死了可用的可执行文件,而该开发者拿了钱就消失了。由于没有源代码,唯一的选择是修补可执行文件,以更改执行流程并绕过锁定。
不幸的是,这并不是一个孤立的案例。老旧工具经常出现需要稍微更改的情况(即使已经存在多年,甚至几十年),然后……嗯,至少有两个选择:
-
源代码丢失,无法在应用更改后重新构建可执行文件。
-
源代码存在,但似乎已经老旧到无法用现代编译器编译,几乎需要从头重写。在这种情况下,即使重写不是大问题,但与软件一起使用的库可能与现代编译器或其输出不兼容,这将使整个项目变得更加复杂,问题依然存在。
根据需要应用的更改复杂度,直接用新代码修补二进制可执行文件可能是一个足够的选择,因为将几个字节放入十六进制编辑器要比逆向工程一个工具(无论是其二进制形式还是已经不再被编译器支持的旧源代码)并从头重写它更简单。
在本章中,我们将考虑一个非常简单的可执行文件示例,目标是进行安全修复。我们将分别为 Windows 和 Linux 创建可执行文件,并首先研究可用的选项,然后应用二进制补丁。由于我们将面向两个平台,我们将在需要时讨论 PE 和 ELF 文件格式。
可执行文件
如前所述,我们必须首先创建可执行文件。寻找一个足够简单、贴合本章内容的现实示例似乎是一个相对困难的任务,因此我们决定采用一个现实中的问题,并用简化的代码进行封装。我们将用 C 语言编写可执行文件的代码,并在 Windows 上使用 Visual Studio 2017 编译,在 Linux 上使用 GCC 编译。代码将简单如以下所示:

如我们所见,这段代码唯一能够做的,就是将用户输入作为字符串读取到一个 128 字节的缓冲区中,为输入字符串分配一个内部缓冲区,将输入字符串复制到其中,并从内部缓冲区打印它。
在 Visual Studio 2017 中创建一个新的解决方案,命名为Legacy,并将前面展示的代码填入其main.cpp文件。个人来说,我更喜欢在编写 C 代码时使用 .c 扩展名,并将“编译方式”选项(可以在项目属性窗口中通过导航到配置属性 | C/C++ | 高级找到)设置为 C。
将前面的代码构建成可执行文件的过程非常简单,除了一个关于 Visual Studio 2017 的细节。当我们尝试伪造一个Legacy可执行文件时,我们需要禁用链接器的动态基址选项。在 Visual Studio 中,右键点击项目并选择“属性”。以下截图展示了动态基址选项的位置:

一旦禁用此选项,只需点击“构建”或“全部构建”即可。
然而,在 Linux 上,我们可以通过在终端输入以下命令之一,像往常一样构建可执行文件(现在先忽略警告):
*# As we are interested in 32-bit executable*
*# on a 32-bit platform we will type:*
gcc -o legacy legacy.c
*# and on a 64-bit platform we will type:*
gcc -o legacy legacy.c -m32
在本章中,我们将首先修补 Windows 可执行文件,然后继续修补 Linux 可执行文件,并查看如何在 ELF 的情况下解决问题。哦,最重要的是;忘记 C 源代码,假装我们没有它们。
问题
无论我们尝试在 Windows 还是 Linux 上运行我们的可执行文件,都几乎不会发现任何问题,因为程序会要求输入我们的名字并将其打印出来。只要程序没有遇到超过 127 个 ASCII 字符的名字(第 128 个字符是结束的 NULL 值),这种方式将稳定工作,然而,确实存在这样的长名字。我们来试着运行这个可执行文件(我们指的是为 Windows 构建的那个,但相同的原理也适用于 Linux 可执行文件),并输入一长串文本,远远超过 127 个字符。结果会是这样:

这个消息的原因是 gets() 函数。如果 C 不是你首选的语言,你可能不知道这个函数不会检查输入的长度,这可能导致堆栈破坏(至少像前面那条消息的出现一样),在最坏的情况下,这也是一个漏洞,容易受到精心制作的攻击。幸运的是,解决 gets() 问题的方法非常简单;必须将对 gets() 的调用替换为对 fgets() 函数的调用。如果我们有源代码,这将是一个一分钟的修复,但我们没有(至少我们假装没有它们)。
然而,我们稍后实现的解决方案并不复杂。我们只需要一个反汇编器(最好是 IDA Pro)、一个十六进制编辑器,当然还有 Flat Assembler。
PE 文件
为了成功地实现补丁,我们需要了解 PE 文件格式(PE 代表便携式可执行文件)。虽然可以通过此 URL 获取详细的规格:www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx,但我们只需要了解格式的几个关键点,并能够手动解析其基本结构。
头文件
一个 PE 文件包含多个头部,第一个我们遇到的是 DOS 头部,它仅包含对我们有用的两个内容;第一个是MZ签名,第二个是文件头的偏移量,也就是 PE 头(因为它之前有PE\x0\x0签名)。文件头包含关于文件的基本信息,例如节的数量。
紧随 PE 头之后的是可选头部,它包含更有趣的信息,如ImageBase——即图像(文件)应加载的首选地址——和NumberOfRvaAndSizes,后者对我们特别重要。NumberOfRvaAndSizes字段表示紧随可选头部之后的IMAGE_DATA_DIRECTORY条目数组中的条目数。IMAGE_DATA_DIRECTORY结构定义如下:
struct IMAGE_DATA_DIRECTORY
{
DWORD VirtualAddress;
DWORD Size;
}
每个结构描述了 PE 文件的特定区域。例如,Import IMAGE_DATA_DIRECTORY,我们特别感兴趣的那个,指的是关于文件中没有的函数的信息,而这些函数是从动态链接库中导入的。
接下来是一个IMAGE_SECTION_HEADER结构数组,其中描述了每个 PE 节(我们会得到节的文件偏移量和大小,以及它的虚拟地址和虚拟大小,即内存中的大小,通常与文件中的大小不同)。
尽管我强烈建议你阅读官方规格,我还建议下载并安装我见过的最好的十六进制编辑器——010 Editor(可以在www.sweetscape.com/010Editor/下载)。这个强大的应用程序除了支持 Windows、macOS 和 Linux 版本,还支持不同二进制格式的模板解析,并且有一个解析 PE 文件的模板。看看模板的输出——它使理解 PE 格式变得更加简单。以下是 010 Editor 中 PE 文件的显示方式:

导入
我们正在寻找的gets()函数是从ucrtbased.dll文件动态链接的,因此我们应该在导入表中查找它。使用 010 Editor 来查找并解析导入表,就像我们在下面的截图中看到的那样,并不困难:

尽管手动解析 PE 可执行文件可能是一个有趣的过程(事实上确实如此),但使用现有工具会更方便、更轻松。例如,IDA Pro 可以为我们完成所有繁琐的工作。
收集信息
将Legacy.exe文件加载到 IDA Pro 或任何你选择的反汇编工具中,我们将开始收集关于如何修补Legacy.exe文件的信息,并强制它使用fgets()代替gets()。
定位gets()调用
我们很幸运,因为在我们的案例中,只有一个对gets()的调用,而且我们知道它应该出现在调用printf的附近,而printf打印出Enter your name:这段字符串。然而,让我们看看 IDA Pro 中的 Strings 窗口:

在最坏的情况下,找到感兴趣的字符串只需要一秒钟,一旦找到,我们只需双击它,进入可执行文件的.rdata部分,在那里我们看到如下内容:

双击DATA XREF:会带我们到代码中字符串被访问的位置:

向下滚动五行,我们看到对j_gets的调用……你可能会问,为什么是j_gets?我们不是在寻找gets()函数的地址,而是跳转到它吗?当然,我们是在寻找gets();然而,由于可能有多个gets()的调用,编译器为此函数创建了一个单独的“调用中心”,这样任何其他调用gets()的代码实际上都会调用j_gets,然后被引导到实际的gets()函数地址,在导入表中。这就是我们在j_gets地址看到的内容:

现在,我们只需要注意call j_gets指令的地址,它是0x4117Ec。
为补丁做准备
不幸的是,我们不能简单地将调用重定向到fgets(),而不是j_gets,因为我们根本没有导入fgets()(因为我们在 C 源代码中没有使用它),而且由于gets()只接受一个参数(如我们在地址0x4117EB处看到的cdecl传递的参数),而fgets()需要三个参数。尝试在原地修补代码,以使其传递三个参数是不可能的,这样会损坏可执行文件并使其无法使用。这意味着我们需要为 shim 代码找到一个位置,该代码将添加两个额外的参数并实际调用fgets()(一旦我们将其添加为导入函数)。
幸运的是,对于我们来说,内存中的 PE 段(实际上,在文件中也是如此)占用的空间比它们的实际内容要大得多。在我们的例子中也是如此,我们需要找到.text段结束的位置;因此,首先我们查看下一个段的开始位置,如下图所示:

正如我们在前面的截图中看到的,下一个段是.rdata,其内容的开始已被高亮显示。一旦我们到达那里,我们开始向上滚动,直到看到非零或0xcc字节的内容,如下图所示:

我们看到实际内容的最后一个字节位于文件偏移 0x4196,因此从文件偏移 0x4197 开始有一些剩余空间;然而,从未对齐的地址开始执行一个过程似乎不太合适,所以我们决定从文件偏移 0x4198 开始。为了确保我们在正确的位置,让我们将这些字节与 IDA Pro 中看到的内容进行对比:

最终,我们看到字节相同,并且可以使用文件偏移 0x4198(虚拟地址 0x414d98)来放置我们的 shim 代码。
导入 fgets()
在我们开始实现补丁之前,我们还需要使可执行文件导入 fgets() 而不是 gets()。这看起来相当简单。让我们看看导入表中 gets() 函数的内容:

找到字符串后,我们可以安全地用 fgets 覆盖它。从以下截图可以看出,为什么在这种特定情况下覆盖是安全的:

前面的截图显示了 gets 被替换为 fgets。我们在这里再次幸运,因为从文件偏移 0x7EF0 开始的 gets 字符串并未以偶数边界结束,因此我们在 0x7EF5 处有一个额外的零,留出了足够的空间来将 gets 替换为 fgets,并且终止的 NULL 保持不变。
补丁调用
下一步将是补丁 gets() 的调用,并将其重定向到我们的 shim。由于我们只有一个 gets() 的调用(现在是一个带有无效参数数量的 fgets() 调用),我们将直接补丁这个调用。如果我们有多个 fgets() 调用,我们将补丁 jmp fgets 指令,而不是对每一个调用进行补丁。
正如我们之前所看到的,调用是相对于 EIP 的,因此我们需要计算一个新的偏移量,使其调用我们位于 0x414d98 的代码。公式相当简单:
new_offset = 0x414d98 - 0x4117EC - 5
这里,0x4117EC 是调用指令的地址,5 是其字节长度。我们需要使用该调用指令的长度,因为在执行时,EIP 已经指向紧接着调用后的指令。计算得到的偏移量为 0x35A7。
然而,在我们应用这个补丁之前,我们必须在十六进制编辑器中找到正确的位置,并使用一些字节表示这个调用指令以及后面的几个字节,如以下截图所示:

我们使用了 0xe8 0xf3 0xfa 0xff 0xff 0x83 0xc4 0x04 字节进行搜索。这样做时,必须确保这样的字节序列在搜索结果中只出现一次。这里的 0xe8 是调用指令,0xf3 0xfa 0xff 0xff 字节是下一条指令的偏移量——0xfffffaf3。以下截图展示了偏移补丁的应用:

偏移量被0x000035a7覆盖。现在,0x4117ec处的指令将调用我们的 Shim 代码。但我们仍然需要实现 Shim 代码。
Shim 代码
我们即将编写的代码看起来会与我们通常编写的代码略有不同,因为我们并不期望从中生成一个可执行文件;相反,我们将生成一个包含假定会加载到特定地址的 32 位过程的二进制文件,这也是我们将在patch.asm源文件的前两行中告诉编译器的内容:
*; Tell the assembler we are writing 32-bit code*
use32
*; Then specify the address where the procedure*
*; is expected to be loaded at*
org 0x414d98
然后,我们将定义两个标签,指向我们过程外的地址。幸运的是,Flat Assembler 允许我们在任意地址定义一个标签,像这样:
*; Assign label to the code where jump*
*; to fgets is performed*
label fgets at 0x414bd8
*; We will discuss this label in just a few seconds*
label __acrt_iob_func at 0x41b180
完成之后,我们就可以开始实现实际的 Shim 代码,作为一个常规的cdecl过程:
fgets_patch:
* ; Standard cdecl prolog*
push ebp
mov ebp, esp
*; Ooops... We need to pass a pointer to*
*; the stdin as one of the fgets' parameters,*
*; but we have no idea what this pointer is...*
Windows 上的标准 C 库实现提供了一个根据流的编号来确定指针的函数。这个函数是__iob_func(int)。幸运的是,我们的目标可执行文件正在从ucrtbased.dll中导入这个函数,正如我们在 IDA Pro 的 Imports 标签(或者在 010 Editor 中)看到的:

尽管名称有些不同(前面加了__acrt_),但这就是我们感兴趣的函数,它位于虚拟地址0x41b180。这也是我们几分钟前添加__acrt_iob_func标签的原因。访问这个地址后,我们可以看到在动态链接后,真正的__acrt_iob_func的地址会被放在那里:

为了调用这个外部函数以获取stdin流的指针,我们必须记住stdin的编号是0,并且导入的函数是间接调用的:
*; Get the stdin stream pointer*
push 0
call dword[__acrt_iob_func]
*; The result is in the EAX register*
*; Do not forget to fix the stack pointer*
*; after calling a cdecl procedure*
add esp, 4
现在,我们已经准备好将执行流转发到fgets(),我们这样做:
*; Forward the call to fgets()*
push eax *; stdin*
push 128 *; max input length*
push dword [ebp + 8] *; forward pointer to the*
* ; input buffer*
call fgets
add esp, 12
*; Standard cdecl epilog*
mov esp, ebp
pop ebp
ret
补丁的代码已经准备好。就这么简单(在这个特定的案例中)。编译这段代码会生成一个包含原始二进制代码的 35 字节二进制文件。这是十六进制编辑器中看到的代码:

应用补丁
在本章的准备补丁小节中,我们已经在十六进制编辑器中找到了补丁应用的位置,即文件偏移量0x4198。应用补丁非常简单——我们将patch.bin文件中的字节复制到可执行文件中的上述位置,并得到以下结果:

现在保存文件,我们就完成了。可执行文件已经打上补丁,从现在开始将使用fgets()代替gets()。我们可以通过运行可执行文件并输入一个非常长的字符串代替名字来检查这一点:

如我们所见,这种输入不再像fgets()那样导致错误,因为最多只会读取 127 个字符,从而保持了栈的安全性,我们在前面的截图中看到了结果;--输出被截断了。
复杂场景
我们刚刚经历了一个简单的 PE 可执行文件打补丁的场景;然而,现实生活中的情况很少如此简单,修改通常比简单地导入不同的函数复杂得多。在这种情况下,有没有办法静态地打补丁到可执行文件呢?当然有。实际上,不止一种方法。例如,可以对文件中的某个过程进行补丁,从而改变它实现的算法。然而,只有当现有过程占用了足够的空间来容纳新代码时,这种方法才可行。另一个选项是向 PE 文件中添加一个可执行部分,这个过程相当简单,值得在这里进行检查。整个过程包含五个简单的步骤(如果修改patch.asm文件算作第六步的话),我们将一一讲解。
准备补丁
这是最简单的一步,因为我们几乎不需要做任何操作。我们已经有一个工作中的补丁代码,唯一的重要区别是从汇编角度来看,代码将放置在内存中的位置。我们将在目标可执行文件的末尾添加一个新部分,因此,代码的加载地址(即Virtual Address)是通过将当前最后一部分的Virtual Address和Virtual Size相加,并将结果四舍五入到最接近的SectionAlignment的倍数来计算的。在我们的情况下,0x1D000 + 0x43C = 0x1d43C,四舍五入到0x1e000。然而,尽管它被称为虚拟地址,但实际上这个值是ImageBase的偏移量,而ImageBase是0x400000,因此真实的虚拟地址应为0x41e000。
简单来说,我们只需要修改patch.asm中的一行——第 2 行,将org 0x414d98改为org 0x41e000。其余代码保持不变。
调整文件头
由于我们打算将部分附加到一个可执行文件中,我们需要对其头部进行一些更改,以便它们能够反映新的实际情况。让我们在 010 编辑器或任何你喜欢的十六进制编辑器中打开Legacy.exe文件,并查看所有头部,在必要的地方进行修改。
在我们更新文件之前,我们必须根据FileAlignment和SectionAlignment的值分别决定文件中新部分的大小(SizeOfRawData)和内存中的大小(VirtualSize)。查看IMAGE_OPTIONAL_HEADER32结构中的这些值,我们发现FileAlignment的值是0x200,SectionAlignment的值是0x1000。由于我们要插入的新代码非常小(只有 35 字节),因此可以使用最小的大小,设定部分的SizeOfRawData = 0x200,VirtualSize = 0x1000。
然而,让我们一步步进行,作为第一步,调整IMAGE_FILE_HEADER下IMAGE_NT_HEADERS的NumberOfSections字段,如下图所示:

原本,文件有七个节,随着我们将增加另一个节,我们将WORD NumberOfSections的值更改为8h。
一旦更新了NumberOfSections字段,我们接着更新IMAGE_OPTIONAL_HEADER32头中的SizeOfImage字段(这是内存中可执行镜像的大小)。SizeOfImage字段的原始值是0x1E000,由于我们的新节应该占用0x1000字节的内存,我们简单地将SizeOfImage设置为0x1F000,如下面的截图所示:

现在进入一个更加有趣的部分——添加一个节头。节头位于IMAGE_DATA_DIRECTORY条目数组之后,在我们的例子中,位于文件偏移量0x1F0。最后一个节头(针对.rsrc节)位于文件偏移量0x2E0,我们将把我们的节头插入在其之后,起始于文件偏移量0x308。对于这个可执行文件,我们有足够的空闲字节,因此可以安全地继续。
节头的前八个字节包含节的名称,我们将节命名为.patch。关于节名称字段的一个有趣的事实是,名称不必以 0(NULL字符串终止符)结尾,并且可以占用所有八个字节。
接下来的四个字节是描述节的虚拟大小的整数(它在内存中将占用多少字节),如我们之前决定的,虚拟大小是0x1000字节(另一个有趣的事实是——我们可以将此字段设置为 0,它仍然能够正常工作)。
接下来的字段是一个四字节整数,描述节的VirtualAddress字段(该节应该被加载到哪里)。该字段的值是之前SizeOfImage字段的值,即0x1E000。
紧随VirtualAddress字段之后的是SizeOfRawData字段(也是 4 个字节),我们将其设置为0x200——即文件中新节的大小——以及
PointerToRawData,我们将其设置为文件之前的大小——0x8E00。
其余字段填充为零,除了最后一个字段Characteristics,我们将其设置为0x60000020,表示该节包含代码并且是可执行的。
你添加的节头应该像下图所示:

添加新节
还有两个步骤,首先是将实际的节数据追加到文件中。在十六进制编辑器中滚动文件到末尾,我们会看到第一个可用的文件偏移量是0x8e00,这正是我们设置的PointerToRawData字段的值。
我们应该将0x200字节附加到文件中,从而将其大小设置为0x9000,并用我们的代码填充这0x200字节的前 35 个字节,如下图所示:

只剩下最后一步,就可以实际运行可执行文件了,别犹豫了。
修复调用指令
剩下的工作就是修复call gets()指令,使其指向我们的新代码。我们使用相同的二进制字符串0xE8 0xF3 0xFA 0xFF 0xFF 0x83 0xC4 0x04来定位我们感兴趣的调用,并将0xF3 0xFA 0xFF 0xFF字节替换为0x0F 0xC8 0x00 0x00,这是从调用后的指令到我们新部分的精确偏移。以下截图准确地展示了这一过程:

最后,保存文件并尝试启动它。如果修补正确,你将看到与之前方法相同的结果。
ELF 可执行文件
修补 ELF 可执行文件比修补 PE 可执行文件要困难一些,因为 ELF 文件通常在其节区中没有空闲空间,因此我们只能选择添加一个节区,这不像 PE 文件那样简单,或者注入共享对象。
添加节区需要对 ELF 格式有很好的了解(可以在www.skyfree.org/linux/references/ELF_Format.pdf中找到相关规范),尽管这一内容非常有趣,但在本书的范围之外。最显著的问题是 ELF 可执行文件中节区和头部的排列方式,以及 Linux 如何处理 ELF 结构,这使得像我们在 PE 修补中那样附加数据变得非常困难。
另一方面,注入共享对象要简单得多,实施起来也容易,因此我们将采用这种方式。
LD_PRELOAD
LD_PRELOAD环境变量由 Linux 动态链接器/加载器ld.so使用,如果设置了它,变量中将包含一个共享对象列表,这些共享对象会在任何其他共享对象之前与可执行文件一起加载,包括libc.so。这意味着我们可以创建一个共享对象,导出一个名为gets的符号,并将这个共享对象指定给LD_PRELOAD,这样如果我们尝试运行的可执行文件导入了一个同名符号,我们的gets实现就会被链接,而不是之后加载的libc.so中的实现。
一个共享对象
现在,我们将实现我们自己的gets()过程,它实际上会将调用转发给fgets(),就像我们之前修补 PE 文件时做的那样。不幸的是,Flat Assembler 对 ELF 的支持目前还无法让我们简单地创建共享对象;因此,我们将创建一个目标文件,并稍后使用 GCC 将其作为 32 位系统的共享对象进行链接。
源代码通常非常简单直观:
*; First the formatter directive to tell*
*; the assembler to generate ELF object file*
format ELF
*; We want to export our procedure under*
*; the name "gets"*
public gets as 'gets'
*; And we need the following symbols to be*
*; imported from libc*
*; As you may notice, unlike Windows, the*
*; "stdin" is exported by libc*
extrn fgets
extrn stdin
*; As we want to create a shared object*
*; we better create our own PLT (Procedure*
*; Linkage Table)*
section '.idata' writeable
_fgets dd fgets
_stdin dd stdin
section '.text' executable
*; At last, the procedure*
gets:
*; Standard cdecl prolog*
push ebp
mov ebp, esp
*; Forward the call to fgets()*
mov eax, [_stdin]
push dword [eax] ; FILE*
push 127 ; len
push dword [ebp + 8] ; Buff*
call [_fgets]
add esp, 12
*; Standard cdecl epilog*
mov esp, ebp
pop ebp
ret
将前面的代码保存为 fgets_patch.asm,并使用 fasm 或 fasm.x64 编译;这将生成 fgets_patch.o 目标文件。将此目标文件构建为共享对象,方法就是在终端运行以下命令之一:
*# On a 32-bit system*
gcc -o fgets_patch.so fgets_patch.o -shared
*# and on a 64-bit system*
gcc -o fgets_patch.so fgets_patch.o -shared -m32
现在让我们在没有补丁的情况下测试并运行旧版可执行文件,并使用一个长字符串(140 字节)进行输入。结果如下:

如我们所见,栈被破坏,导致了段错误(无效的内存访问)。现在我们可以尝试运行相同的可执行文件,但将 LD_PRELOAD 环境变量设置为 "./fgets_patch.so",从而在启动 legacy 可执行文件时强制加载我们的共享对象。命令行将如下所示:
LD_PRELOAD=./fgets_patch.so ./legacy
这次,我们得到了预期的输出——被截断到 127 个字符——这意味着我们的 gets() 实现通过动态链接过程进行了链接:

总结
修改现有可执行代码和/或正在运行的进程是一个相当广泛的主题,十分难以在单一章节中涵盖,因为这个主题本身可能值得独立成书。然而,它与编程技术和操作系统的关系更为紧密,而我们试图专注于汇编语言。
本章几乎只是触及了所谓的二进制代码修改(即补丁)的冰山一角。目的在于展示这个过程是多么简单和有趣,而不是详细讨论每一种方法。然而,我们已经获得了一个大致的方向,当涉及到那些无法简单重建的代码修改时,应该去哪里。
代码分析的方法仅被表面性地涵盖,目的是为你提供一个大致的概念,应用程序补丁过程的大部分内容也是如此,因为重点是补丁的实现。我的个人建议是——去了解 Windows PE 可执行文件和目标文件格式规范,以及 Linux ELF。即使你永远不需要修改任何可执行文件,了解这些内容也能帮助你理解在高级语言编程时,底层发生了什么。
第十一章:哦,差点忘了
我们的旅程接近尾声。然而,需要明确的是,这本书仅仅涵盖了名为汇编语言编程的冰山一角,前方还有更多的内容等待你去学习。本书的主要目的是向你展示如何在汇编语言中创建强大而简便的软件,以及它的可移植性和便利性。
在本书的过程中,我们还有一些话题没有涉及,但这些话题仍然值得关注。一个这样的主题是我们如何防止我们的代码被偷偷窥探。我们将简要介绍如何通过 Flat Assembler 实现一些保护代码的方法,而不需要第三方软件的支持。
另一个在我看来很有趣且值得探讨的话题是如何编写可以在内核空间中执行的代码。我们将为 Linux 实现一个小型可加载的内核模块。
保护代码
有很多书籍、文章和博客帖子讨论如何更好地保护代码。它们中的一些确实有用且实际;然而,大多数都是专门针对某些第三方工具或它们的组合。我们不会对这些内容进行评审,无论是书籍还是工具。相反,我们将看看我们自己能利用现有工具做些什么。
首先,我们必须接受一个事实,那就是没有任何东西能为我们的代码提供 100%的保护。不管我们做什么,只要我们的代码越有价值,就越可能被逆向工程。我们可以使用打包工具、保护工具以及任何其他我们能想到的工具,但最终它们都是众所周知的,并且总有一种方法可以绕过它们。因此,最终的防线就是代码本身。更准确地说,就是代码呈现给潜在攻击者的方式。这就是混淆的作用所在。
混淆这个词的字典定义是使某物变得模糊、不清楚或难以理解。它可能是一种非常强大的技术,无论是与其他方法结合使用还是单独使用。我曾有机会逆向工程一个广泛使用加密的程序。这个程序没有使用任何第三方工具进行保护,而是采用了一种非常巧妙且模糊(乍一看)的比特操作,我不得不承认——这比如果使用像Themida这样的工具,逆向工程会更加困难。
在本章的这一部分,我们将看到一个混淆的简单示例,通过稍微增强我们为 Windows 可执行文件使用gets()时做的补丁。由于混淆不是本书的主要话题;我们不会深入细节,而是展示一些简单而微小的改变是如何使理解代码的基本逻辑变得稍微困难,而不必在调试器中动态观察它。
原始代码
让我们先快速浏览一下我们作为补丁一部分植入到可执行文件中的原始代码。代码非常简单,考虑到我们已经了解的内容,阅读起来很容易:
*; First of all we tell the assembler*
*; that this is a 32-bit code*
use32
*; Tell the assembler that we are expecting*
*; this code to appear at 0x41e000*
org 0x41e000
*; Define labels for "external" procedures*
*; we are about to use*
label fgets at 0x414bd8
label __acrt_iob_func at 0x41b180
*; Implement the procedure*
fgets_patch:
*; We begin the procedure with the standard*
*; prolog for cdecl calling convention*
push ebp
mov ebp, esp
*; As we need the pointer to the stdin stream*
*; we call the __acrt_iob_func procedure*
push 0 *; This is the number of the stream*
call dword[__acrt_iob_func]
add esp, 4 *; Restore the stack pointer
; Forward the parameter (char*) and
; invoke fgets()* push eax *; Contains pointer to the stdin stream*
push 128 *; Maximum input length*
push dword[ebp + 8] *; Pointer to the receiving buffer*
call fgets
add esp, 4 * 3 *; Restore the stack pointer
; Standard epilog for procedures using cdecl
; calling convention* mov esp, ebp
pop ebp
ret
代码相当简单,而且很难从中找到任何有价值的保护内容。鉴于这种情况,我们将使用这个例子来展示如何简单地用其他指令实现call指令,使得它既不指向被调用的函数,也完全不像一个过程调用。
调用
有几种方法可以用一系列指令替换call指令,这些指令会执行完全相同的操作,但会被反编译器以不同的方式处理。例如,以下代码将完全执行call指令的功能:
*; Preceding code*
push .return_address *; Push the return address on stack*
push .callee *; Redirect the execution flow to*
ret *; callee*
.return_address:
*; the rest of the code*
我们也可以替换以下序列:
push callee
ret
例如:
lea eax, [callee]
jmp eax
这样仍然会产生相同的结果。然而,我们希望我们的混淆更强一些;因此,我们继续并创建一个宏。
调用混淆宏
在开始混淆call指令之前,我们将定义一个名为random的实用宏:
*; The %t below stands for the current*
*; timestamp (at the compile time)*
random_seed = %t
*; This macro sets 'r' to current random_seed*
macro random r
{
random_seed = ((random_seed *\
214013 +\
2531011) shr 16) and 0xffffffff
r = random_seed
}
random宏生成一个伪随机整数,并将其返回到参数变量中。我们需要这一小段随机化代码来为我们的call实现添加一些多样性。该宏本身(我们称之为f_call)使用了 EAX 寄存器;因此,我们要么在调用f_call之前保存这个寄存器,要么只在返回值存放在 EAX 寄存器的过程中使用该宏,因为否则寄存器中的值将会丢失。此外,由于它处理参数的方式,它仅适用于直接调用。
最后,我们来看一下宏本身。由于理解代码的最佳方式是查看代码,让我们深入了解这个宏:
*; This macro has a parameter - the label (address)*
*; of the procedure to call*
macro f_call callee
{
*; First we declare a few local labels*
*; We need them to be local as this macro may be*
*; used more than once in a procedure*
local .reference_addr,\
.out,\
.ret_addr,\
.z,\
.call
*; Now we need to calculate the reference address*
*; for all further address calculations*
call .call
.call:
add dword[esp], .reference_addr - .call
*; Now the address or the .reference_addr label*
*; is at [esp]*
*; Jump to the .reference_addr*
ret
*; Add some randomness*
random .z
dd .z
*; The ret instruction above returns to this address*
.reference_addr:
*; Calculate the address of the callee:*
*; We load the previously generated random bytes into*
*; the .z compile time variable*
load .z dword from .reference_addr - 4
mov eax, [esp - 4] *; EAX now contains the address*
*; of the .reference_addr label*
mov eax, [eax - 4] *; And now it contains the four*
*; random bytes*
xor eax, callee xor .z *; EAX is set to the address of*
*; the callee*
*; We need to set up return address for the callee*
*; before we jump to it*
sub esp, 4 *; This may be written as*
*; 'add esp, -4' for a bit of*
*; additional obfuscation*
add dword[esp], .ret_addr - .reference_addr
*; Now the value stored on stack is the address of*
*; the .ret_addr label*
*; At last - jump to the callee*
jmp eax
*; Add even more randomness*
random .z
dd .z
random .z
dd .z
*; When the callee returns, it falls to this address*
.ret_addr:
*; However, we want to obfuscate further execution*
*; flow, so we add the following code, which sets*
*; the value still present on stack (address of the*
*; .ret_addr) to the address of the .out label*
sub dword[esp - 4], -(.out - .ret_addr)
sub esp, 4
ret
*; The above two lines are, in fact, an equivalent*
*; of 'jmp dword[esp - 4]'*
*; Some more randomness*
random .z
dd .z
.out:
}
如我们所见,这个混淆尝试并没有涉及复杂的计算,甚至代码仍然是可读且易于理解的,但让我们将patch_section.asm文件中的call fgets这一行替换为f_call fgets,重新编译并重新应用补丁到可执行文件。
新的补丁明显变大了——从 35 字节变成了 86 字节:

将这些字节复制并粘贴到Legacy.exe文件的0x8e00偏移位置,如下图所示:

运行可执行文件后,我们将获得与上一章相同的结果,因此在这一阶段没有明显的区别。不过,让我们来看一下代码在反汇编器中的样子:

我们不能说这里的代码被严重混淆了,但它应该能让你了解使用相对简单的宏与 Flat Assembler 配合时可以做些什么。前面的例子仍然可以通过一点努力读取,但应用更多的混淆技巧会让它变得几乎无法阅读,且在没有调试器的情况下几乎无法还原。
一点内核空间
直到现在,我们一直在处理用户空间的代码,编写小型应用程序。然而,在本章的这一部分,我们将为 Linux 实现一个小而简单的可加载内核模块(LKM)。
几年前,我参与了一个有趣的项目,目标是识别由某些内核模块处理的数据。由于我不仅无法访问内核源代码,还无法访问内核本身,更不用说这不是一个 Intel 平台,这个项目变得更加具有挑战性。我所知道的只有相关内核的版本,以及目标模块的名称和地址。
我经历了一个漫长而有趣的过程,直到我能够构建一个能够完成我需要的工作的 LKM。最终,我成功构建了一个用 C 编写的 LKM,但如果我不尝试用汇编语言编写一个,我就不会满足自己。这是一次难忘的经历,我必须承认。然而,一旦项目完成,我决定尝试在我的开发机器上实现一个简单的 LKM。由于第一个模块是为不同的平台编写的,且针对不同版本的内核,并且考虑到我决定假装自己没有当前内核的源代码,我不得不进行几乎同样多的研究和逆向工程,即使我编写的是我自己系统的模块。
LKM 结构
让我来为你省去同样漫长的挖掘信息、逆向其他内核模块的结构和检查内核源代码的过程,以便弄清楚模块是如何加载的。相反,我们直接进入 LKM 的结构。
可加载内核模块实际上是一个 ELF 对象文件,带有一些额外的部分和一些信息,这些信息在用户空间创建的目标文件和可执行文件中通常是看不到的。我们应该指出至少五个通常在常规文件中没有的部分:
-
.init.text:这一部分包含模块初始化所需的所有代码。以 Windows 为例,这部分内容可以与DllMain()函数及其引用的所有函数进行比较。对于 Linux 来说,它可以被看作是一个包含构造函数的部分(Windows 可执行文件也可能包含该部分)。 -
.exit.text:这一部分包含在模块卸载之前需要执行的所有代码。 -
.modinfo:这一部分包含有关模块本身的信息、它所写的内核版本等。 -
.gnu.linkonce.this_module:此部分包含this_module结构体,后者包含模块的名称以及指向模块初始化和去初始化过程的指针。尽管对于我们来说,这个结构体本身有点模糊,但我们只对某些特定的偏移量感兴趣,在没有源代码的情况下,可以使用逆向工程工具(如 IDA Pro)找到这些偏移量。不过,我们仍然可以通过在终端中运行readelf命令,查看.init.text和.exit.text指针在结构体中的偏移量,方法如下:readelf- sr name_of_the_mofule.ko然后,我们看到输出中的偏移量:
如我们所见,指向 .init.text的指针位于偏移量0x150,而指向.exit.text的指针则位于this_module结构体的偏移量0x248处。 -
__versions:此部分包含外部符号的名称,并附带其版本号。内核使用该表格来验证相关模块的兼容性。
LKM 源代码
LKM 的结构并不神秘。它可以从 Linux 内核源代码中获取,这些源代码是公开的,因此我们无需进一步探究;相反,根据奥卡姆剃刀原则,让我们继续实现模块。
如前所述,LKM 是一个目标文件;因此,我们首先创建一个 lkm.asm 文件,并按如下方式输入我们的代码:
format ELF64 *; 64-bit ELF object file*
extrn printk *; We are going to use this symbol,*
*; exported by the kernel, in order to*
*; have an indication of the module being*
*; loaded without problems*
紧接着,我们可以开始创建 LKM 的各个部分。
.init.text
本部分包含成功初始化 LKM 所需的代码。在我们的情况下,由于我们没有为模块添加任何功能,它可以直接返回,但由于我们需要表示我们的 LKM 成功加载,因此我们将实现一个小过程,向系统日志中打印一条字符串:
section '.init.text' executable
module_init:
push rdi *; We are going to use this register*
mov rdi, str1 *; Load RDI with the address of the string*
*; we want to print to system log (we will*
*; add it to the data section in a few moments)*
xor eax, eax
call printk *; Write the string to the system log*
xor eax, eax *; Prepare return value*
pop rdi *; Restore the RDI register*
ret
相当简单,是不是?我们只需打印字符串并从该过程返回。
.exit.text
该部分的内容将更加简单(在我们这个具体情况下)。我们只需从过程返回:
section '.exit.text' executable
module_cleanup:
xor eax, eax
ret
由于我们没有分配任何资源,也没有加载任何模块或打开任何文件,因此我们直接返回 0。
.rodata.str1.1
这是一个只读数据部分,唯一需要放入其中的内容是我们将写入系统日志的字符串:
section '.rodata.str1.1'
str1 db '<0> Here I am, gentlemen!', 0x0a, 0
.modinfo
在本节中,我们需要提供关于我们模块的某些信息,例如许可证、依赖项,以及内核版本和支持的选项:
section '.modinfo'
*; It is possible to specify another license here,*
*; however, some kernel symbols would not be*
*; available for license other than GPL*
db 'license=GPL', 0
*; Our LKM has no dependencies, therefore, we leave*
*; this blank*
db 'depends=', 0
*; Version of the kernel and supported options*
db 'vermagic=3.16.0-4-amd64 SMP mod_unload modversions ', 0
如果你不确定应该指定什么作为 vermagic,你可以在 ''/lib/modules/uname -r/'' 目录中的任何模块上运行 modinfo 命令。例如,我在我的系统上运行以下命令:
/sbin/modinfo /lib/modules/`uname -r`/kernel/arch/x86/crypto/aesni-intel.ko
输出将如下截图所示:

一旦你获得这些信息,你可以简单地复制 vermagic 字符串并将其粘贴到你的代码中。
.gnu.linkonce.this_module
这里没有什么特别要说的。此部分只包含一个结构体--this_module,它大部分都填充为零(因为它在 LKM 加载器内部使用),除了三个字段:
-
模块名称
-
指向初始化过程的指针--
module_init -
指向反初始化过程的指针--
module_cleanup
在这个内核版本和 Linux 发行版中,这些字段分别位于偏移量0x18、0x150和0x248的位置;因此,代码将如下所示:
section '.gnu.linkonce.this_module' writeable
this_module:
*; Reserve 0x18 bytes*
rb 0x18
*; String representation of the name of the module*
db 'simple_module',0
*; Reserve bytes till the offset 0x150*
rb 0x150 - ($ - this_module)
*; The address of the module_init procedure*
dq module_init
*; Reserve bytes till the offset 0x248*
rb 0x248 - ($ - this_module)
*; The address of the module_cleanup procedure*
dq module_cleanup
dq 0
这就是我们在这一部分需要处理的全部内容。
__versions
本节中的信息通过版本号和名称描述外部符号,并由加载器使用,以确保内核和 LKM 使用相同版本的符号,从而避免出现任何意外。你可以尝试在没有此部分的情况下构建模块,甚至可能加载它,但不建议这样做。加载器会拒绝加载版本无效的模块,这告诉我们这些信息并非只是为了好玩,而是为了防止失败。
当时,我找不到关于如何获取某些符号版本号的可靠信息,但这可能是一个不错的变通办法,这对我们的小 LKM 足够用,方法是简单地查找以 8 字节版本值(在 32 位系统上为 4 字节)为前缀的符号名,如下截图所示:

我们的 LKM 只需要两个外部符号,分别是module_layout和printk。正如你在前面的截图中看到的,module_layout符号的版本是0x2AB9DBA5。采用相同的方法获取printk符号的版本号,我们得到了(在我的系统上是如此,但在你的系统上可能不同)0x27E1A049。
这些条目作为结构体数组存储,其中每个结构体包含两个字段:
-
版本号:这是 8 字节版本标识符(在 32 位系统上为 4 字节) -
符号名称:这是一个变长字符串(最多 56 字节),表示符号的名称
由于我们在讨论的是固定大小的字段,因此定义一个结构体是自然的;但是,由于我们不想为每个符号命名每一个结构体,我们将使用宏:
macro __version ver, name
{
local .version, .name
.version dq ver
.name db name, 0
.name_len = $ - .name
rb 56 - .name_len
}
定义了__version宏后,我们准备好方便地实现__versions部分:
section '__versions'
__version 0x2AB9DBA5, 'module_layout'
__version 0x27E1A049, 'printk'
就这样。保存文件,试着编译并加载它。
测试 LKM
测试模块比编写模块要简单得多。编译与通常的方式没有不同;我们只需使用 Flat Assembler 进行编译:
*# It is just the name of the output file that differs*
*# The extension would be 'ko' - **k**ernel **o**bject, instead*
*# of 'o' for regular **o**bject*
fasm lkm.asm lkm.ko
一旦我们的内核模块被编译完成,我们需要确保它已经设置了可执行属性,可以通过在终端运行chmod +x lkm.ko命令来完成。
为了将 LKM 加载到当前运行的内核中,我们使用以下方式的insmode命令:
sudo /sbin/insmode ./lkm.ko
除非 LKM 的格式存在严重问题(例如,符号版本无效),否则不会出现任何错误。如果一切顺利,可以尝试在终端中运行 dmesg 命令,像这样:
dmesg | tail -n 10
你应该能看到 "<0> Here I am, gentlemen!" 字符串出现在系统日志的末尾。如果该字符串没有出现,那么很可能你需要重启系统,但首先可以尝试通过在终端中运行 rmmod 命令卸载模块,像这样:
sudo /sbin/rmmod simple_module
如果一切顺利,我们现在应该能够使用纯汇编语言创建 Linux LKM(加载内核模块)。
总结
我们已经走了很长一段路。从英特尔架构的概述开始,我们经历了不同算法的实现,尽管为了便于理解,大多数算法进行了简化,最后我们实现了一个适用于 Linux 的可加载内核模块。
本章最后部分的目的是引起你对一些超出本书范围的话题的兴趣,因此这些话题无法得到足够的关注,但它们仍然在某种程度上很重要。尽管本章开始时给出的混淆方法相对简单,但它应该让你对如何使用 Flat Assembler 提供的基本工具——宏引擎,提出更复杂的混淆方案有一个大致的了解。
我们在本章的第二部分投入了一些时间来讲解内核编程,尽管我们实现的内核模块可能是最基础的一个,但我们已经展示了即便是内核开发这些许多人认为非常复杂的编程领域,即使从高级语言的角度来看,实际上也没有什么值得害怕的,尤其是从被称为汇编语言的坚硬岩石的巅峰来看。
到目前为止,你应该已经有了足够扎实的基础,可以轻松继续前进并提高你的汇编编程技能和能力,祝你在这个过程中好运。
谢谢!


如我们所见,指向
浙公网安备 33010602011771号