1、背景
计算机语言具有高级语言和低级语言之分。而高级语言又主要是相对于汇编语言而言的,它是较接近自然语言和数学公式的编程,基本脱离了机器的硬件系统,用人们更易理解的方式编写程序。编写的程序称之为源程序。低级语言分机器语言(二进制语言)和汇编语言(符号语言),这两种语言都是面向机器的语言,和具体机器的指令系统密切相关。机器语言用指令代码编写程序,而符号语言用指令助记符来编写程序。高级语言、汇编语言和机器语言都是用于编写计算机程序的语言。
1.1定义
机器语言将指令编码为 0 和 1 的序列;这种二进制编码是计算机的处理器被构建来执行的。但是,使用这种编码编写程序对于人类程序员来说是笨拙的。因此,当程序员想要指示计算机要执行的精确指令时,他们会使用汇编语言,该语言允许以文本形式编写指令。汇编器将包含汇编语言代码的文件翻译成相应的机器语言。
让我们看一个 ARM 设计的简单示例。这是机器语言指令:
1110 0001 1010 0000 0011 0000 0000 1001
当处理器被告知执行该二进制序列时,它会将值从“register 9”复制到“register 9”。但作为程序员,您几乎不想阅读长二进制序列并理解它。相反,程序员更喜欢用汇编语言进行编程,我们将使用以下行来表达这一点。
MOV R3, R9
然后程序员将使用汇编程序将其转换为计算机实际执行的二进制编码。
但不只是只有一种机器语言:为每条处理器设计了不同的机器语言,旨在提供一组强大的快速指令,同时允许构建相对简单的电路。处理器通常被设计为与以前的处理器兼容,因此它遵循相同的机器语言设计。例如,英特尔的处理器系列(包括 80386、奔腾和酷睿 i7)支持类似的机器语言。但是 ARM 处理器支持完全不同的机器语言。机器语言编码的设计称为指令集架构(ISA,instruction set architecture)。
并且对于每种机器语言,必须有不同的汇编语言,因为汇编语言必须对应于一组完全不同的机器语言指令。
1.2不同的指令集架构(ISA)
在众多 ISA(指令集架构)中,x86 是最广为人知的。它最初由 Intel 于 1974 年设计用于 8 位处理器(Intel 8080),多年来它扩展到 16 位形式(1978,Intel 8086),然后扩展到 32 位形式(1985,Intel 80386),然后是 64 位形式(2003,AMD Opteron)。今天,支持 IA32 的处理器现在由 Intel、AMD 和 VIA 制造,并且可以在大多数个人计算机中找到。
今天另一个著名的 ISA 是 PowerPC。Apple 的 Macintosh 计算机一直使用这些处理器,直到 2006 年 Apple 将其计算机切换到 x86 系列处理器。但 PowerPC 仍然普遍用于汽车和游戏机(包括 Wii、Playstation 3 和 XBox 360)等应用程序。
但我们将研究的 ISA 来自一家名为 ARM 的公司。(与其他成功的 ISA 一样,ARM 的 ISA 多年来一直在发展。我们将研究 4T 版本。)支持 ARM ISA 的处理器分布相当广泛,通常用于手机、数字音乐播放器和手持游戏系统等低功耗设备。iPhone、Kindle 和 Nintendo DS 都是采用 ARM 处理器的设备的突出例子。
我们研究 ARM 的 ISA 而不是 IA32 有几个原因。
汇编语言编程很少用于功能更强大的计算系统,因为用高级编程语言进行编程要容易得多。但是对于小型设备,汇编语言编程仍然很重要:由于功率和价格的限制,设备的资源非常少,开发人员可以使用汇编语言尽可能高效地使用这些资源。
IA32 架构的多个扩展导致它过于复杂,以至于我们无法真正彻底理解。
IA32 可以追溯到 1970 年代,那是一个完全不同的计算时代。ARM 更能代表更现代的 ISA 设计。
2ARM汇编语言基础
2.1一个简单的程序:数字求和
让我们从一个简单的例子开始我们的介绍。想象一下,我们想要将 1 到 10 的数字相加。我们可以在 C 语言中这样做,如下所示。
下面将其翻译成 ARM 的 ISA 支持的指令。
您会注意到在汇编语言程序中提到了 R0 和 R1。这些是对寄存器的引用,它们位于处理器中,用于在计算期间存储数据。ARM 处理器包括 16 个易于访问的寄存器,编号为 R0 到 R15。每个都存储一个 32 位数字。请注意,尽管寄存器存储数据,但它们与内存的概念非常不同:内存通常更大(千字节或通常千兆字节),因此它通常存在于处理器之外。由于内存的大小,访问内存比访问寄存器需要更多的时间——通常大约是 10 倍。因此,汇编语言编程倾向于尽可能关注使用寄存器。
因为汇编语言程序的每一行都直接对应于机器语言,所以这些行的格式受到严格限制。可以看到每一行由两部分组成:首先是操作码,例如 MOV,它是表示操作类型的缩写;之后是诸如“R0,#0”之类的参数。每个操作码对允许的参数都有严格的要求。例如,一条 MOV 指令必须正好有两个参数:第一个必须标识一个寄存器,第二个必须提供一个寄存器或一个常量(以“#”为前缀)。直接放在指令中的常量称为立即数,因为处理器在读取指令时可以立即使用它。
在上述汇编语言程序中,我们首先使用 MOV 指令将 R0 初始化为 0,将 R1 初始化为 10。ADD 指令计算 R0 和 R1 的和(第二个和第三个参数)并将结果放入 R0(第一个参数) );这对应于总数 += i;等效 C 程序的行。随后的 SUBS 指令将 R1 减 1。
要理解下一条指令,我们需要了解除了寄存器 R0 到 R15 之外,ARM 处理器还包含一组四个“标志”,分别标记为零标志 (Z)、负标志 (N)、进位标志 (C) 和溢出标志 (V)。每当算术指令的末尾有一个 S 时,就像 SUBS 一样,这些标志将根据计算结果进行更新。在这种情况下,如果 R1 减 1 的结果为 0,则 Z 标志将变为 1;N、C 和 V 标志也更新了,但它们与我们对此代码的讨论无关。
下面的指令 BNE 将检查 Z 标志。如果未设置 Z 标志(即,先前的减法给出非零结果),则 BNE 安排处理器,以便执行的下一条指令是 ADD 指令,再次标记;这导致以较小的 R1 值重复循环。如果设置了 Z 标志,处理器将继续执行下一条指令。(BNE 代表 Branch if Not Equal。这个名字来源于想象我们想要检查两个数字是否相等。使用 ARM 的 ISA 执行此操作的一种方法是首先告诉处理器减去两个数字;如果差是零,那么这两个数字必须相等,零标志将为 1。它们导致零,这将设置零标志。)
最后一条指令 B 总是分支回到指定的指令。在这个程序中,指令为自己命名,通过使计算机进入一个紧密的无限循环来有效地停止程序。
2.2另一个例子:冰雹序列
输入任何一个大于1的正整数N,如果是偶数的话就除以2,如果是奇数的话就乘以3再加上1,最后这个数都会变为1。特殊地,当输入为1时,序列为1。这就是冰雹序列。其公式如下:
现在,让我们考虑一下冰雹序列。给定一个整数 n,我们反复想应用以下过程:
迭代次数←0
当 n ≠ 1 时:迭代器←迭代器+1
如果 n 是奇数:n ← 3 ⋅ n + 1
别的:n ← n / 2
例如,如果我们从 3 开始,那么因为这是奇数,所以我们的下一个数字是 3 ⋅ 3 + 1 = 10。这是偶数,所以我们的下一个数字是 10 / 2 = 5。这是奇数,所以我们的下一个数字是3 ⋅ 5 + 1 = 16。这是偶数,所以我们转到 8,它仍然是偶数,所以我们转到 4,然后是 2 和 1。
在将其翻译成 ARM 的汇编语言时,我们必须面对一个事实,即 ARM 缺少任何与除法相关的指令。(设计人员认为除法很少需要在它所需的复杂电路上浪费晶体管。)幸运的是,该算法中的除法相对简单:我们只需将 n 除以 2,这可以通过右移来完成。
ARM 有一种不同寻常的移位方法:我们已经看到,每条基本算术指令,最后的参数都可以是常量(如 SUBS R1、R1、#1)或寄存器(如 ADD R0、R0、R1)。但是当最后一个参数是一个寄存器时,我们可以选择添加一个移位距离:例如,指令“ADD R0, R0, R1, LSL #1”。表示在将 R1 添加到 R0 之前添加左移版本的 R1(而 R1 本身保持不变)。ARM 指令集支持四种类型的移位:
LSL |
logical shift left(逻辑左移); |
LSR |
logical shift right(逻辑右移); |
ASR |
arithmetic shift right(算术右移); |
ROR |
rotate right(向右旋转); |
移位距离可以是 1 到 32 之间的立即数,也可以基于寄存器值:“MOV R0, R1, ASR R2”等价于“R0 = R1 >> R2”。
在将我们的伪代码翻译成汇编语言时,我们会发现移位操作对于将 n 乘以 3(计算为 n + (n « 1))和除以 n (计算为 n » 1)都很有用。我们还需要处理测试 n 是否为奇数。我们可以通过测试 n 的 1 位是否设置来做到这一点,我们可以使用 ANDS 指令与 1 进行按位与来完成。ANDS 指令根据结果是否为 0 设置 Z 标志。如果结果为 0,那么这意味着n的1位是0,所以n是偶数。
2.3另一个例子:添加数字
让我们看另一个例子。在这里,假设我们要添加一个正数的数字;例如,给定数字 1024,我们想要计算 1 + 0 + 2 + 4,即 7。用 C 语言表达这一点的明显方法如下。
但是,很难将其转换为 ARM 的 ISA,因为 ARM 没有任何除法指令。但是,我们可以使用一个巧妙的技巧来使用乘法来执行这种除法:如果我们将一个数字乘以 232 / 10,则乘积的高 32 位告诉我们将原始数字除以 10 的结果。这种解法导致以下替代方法对数字中的数字求和。
在将其翻译成汇编代码时,我们必须面对两个问题。更明显的是确定使用哪个指令来执行乘法。在这里,我们要使用 UMULL 指令(Unsigned MULtiply Long),它将两个寄存器解释为无符号的 32 位数字,并将寄存器值的 64 位乘积放入两个不同的寄存器中。下面的例子说明了。
我们必须面对的不太明显的问题是将 0x1999999A 放入寄存器中。一开始您可能会想使用 MOV,但这条指令有一个主要限制:任何立即数都必须循环偶数位才能达到 8 位值。对于 0 到 255 之间的数字,这不是问题;对于 1,024 也不是问题,因为 0x400 可以通过将 1 向左旋转 12 位来实现。但是对于 0x1999999A 没有办法做到这一点。我们将使用的解决方案是分别加载每个字节,使用 ORR 指令将它们连接起来,该指令计算两个值的按位或。
顺便说一句,您有时可能希望将一个小的负数(如 -10)放入寄存器中。您不能使用 MOV 来完成此操作,因为它的二进制补码表示为 0xFFFFFFF6,无法旋转为 8 位数字。如果碰巧知道某个寄存器包含数字 0,那么您可以使用 SUB。但如果不是,则 MVN(MoVe Not)指令很有用:它将其参数的按位 NOT 放入目标寄存器。因此,要将 -10 放入 R0,我们可以使用“MVN R0,#0x9”。
2.4计算的指令摘要
ARM 包括 16 条“基本”算术指令,编号从 0 到 15。下面列出了所有 16 条指令,其功能由相关的 C 运算符总结。(每行开头的数字用于将指令翻译成机器语言。程序员没有理由记住这种对应关系:毕竟,这就是我们有汇编程序的原因。)
除 TST、TEQ、CMP 和 CMN 外,所有指令都可以在操作码后缀 S 以表示操作应设置标志。对于 TST、TEQ、CMP 和 CMN,S 是隐含的:指令不会更改任何通用寄存器,因此执行指令的唯一要点是设置标志。
我们还看到了上述基本算术指令中没有的其他三个操作码:UMULL 是“非基本”算术指令,B 和 BNE 不是算术指令。
2.5条件代码
每条 ARM 指令都可以包含一个条件代码,指定该操作仅在某些标志组合成立时才发生。您可以通过将条件代码包含在操作码中来指定条件代码。它通常出现在操作码的末尾,但它在基本算术指令上的可选 S 之前。条件代码的名称是基于假设标志是基于 CMP 或 SUBS 指令设置的。
到目前为止,我们看到的这个条件代码的唯一实例是 BNE 指令:在这种情况下,我们有一个用于分支的 B 指令,但只有在 Z 标志为 0 时才会发生分支。
但是 ARM 的 ISA 也允许我们将条件代码应用于其他操作码。例如,ADDEQ 表示如果 Z 标志为 1,则执行加法。在非分支指令上使用条件代码的一种常见情况是使用 Euclid 的 GCD 算法计算两个数字的最大公约数。
传统的汇编语言翻译只会在分支指令上使用条件代码。
但是,以下是更短且更有效的翻译。
由于两个原因,这更有效。更明显的是,每次迭代执行的指令数量更少(四个对五个)。但另一个原因来自现代处理器在执行当前指令时“预取”下一条指令的事实。但是,由于无法确定下一条指令的位置,因此分支会中断此过程。第二次翻译涉及更少的分支指令,因此预取指令的问题更少。
3存储(Memory)
我们已经了解了如何构建执行基本数值计算的汇编程序。我们现在将转向检查汇编程序如何访问内存。
3.1基本内存指令
ARM 支持通过两条指令 LDR 和 STR 访问内存。LDR 指令从内存中加载数据,STR 将数据存储到内存中。每个都有两个参数。第一个参数是数据寄存器:对于一条 LDR 指令,加载的数据放在这个寄存器中;对于 STR 指令,在该寄存器中找到的数据存储到内存中。第二个参数表示包含正在访问的内存地址的寄存器;它将使用括号中的寄存器名称写入。
有关这些指令如何工作的示例,假设我们需要一个汇编程序片段,它将整数添加到数组中。我们假设 R0 保存数组的第一个整数的地址,R1 保存数组中整数的个数。
在这个片段中,我们使用 R4 来保存到目前为止的整数之和。在 LDR 指令中,我们在 R0 中查找内存地址并将在该地址找到的数据加载到 R2 中。然后我们将此值添加到 R4 中。然后,我们移动 R0 使其包含数组中下一个整数的内存地址;我们将 R0 增加四,因为每个整数消耗四个字节的内存。最后,我们递减 R1,这是要从数组中读取的整数个数,如果还有整数,我们重复这个过程。
LDR 和 STR 都加载和存储 32 位值。还有使用 8 位值、LDRB 和 STRB 的说明;这些主要用于处理字符串。下面是 C 的 strcpy 函数的实现;我们假设 R0 保存目标数组的第一个字符的地址,而 R1 保存源字符串的第一个字符的地址。我们希望继续复制,直到我们复制终止 NUL 字符(ASCII 0)。
3.2寻址模式
在上一节的示例中,我们通过将寄存器名称括在括号中来提供地址。但是 ARM 也允许使用其他几种方式来指示内存地址。每一种这样的技术都称为寻址模式。简单地命名保存内存地址的寄存器的技术就是一种这样的寻址模式,称为寄存器寻址,但还有其他的。
其中之一是缩放的寄存器偏移量,我们在括号中包括一个寄存器、另一个寄存器和一个移位值。为了计算要访问的内存地址,处理器获取第一个寄存器,并将根据移位值移位的第二个寄存器添加到它。(括号中提到的寄存器都不会改变值。)当访问知道数组索引的数组时,这种寻址模式很有用。我们可以修改之前的例程,将整数添加到数组中,以利用这种寻址模式。
对于循环的每次迭代,我们首先减少循环索引 R1。然后我们使用缩放的寄存器偏移量检索数组条目处的元素:我们使用 R0 作为我们的基数,并将 R1 添加到它左移两个位置。我们将 R1 左移两位,使 R1 乘以 4;毕竟,数组中的每个整数都是四个字节长。将加载的值添加到 R4 中,累加总数后,如果 R1 尚未达到 0,我们重复循环。
除了使用不同的寻址模式之外,这个版本的代码在三个方面与我们的原始实现略有不同。首先,它以相反的顺序加载数组中的数字——也就是说,它首先加载数组中的最后一个数字。其次,R0 在片段的过程中保持不变。最后,它会更快一些,因为它在每次循环迭代中减少了一条指令。
立即后索引寻址是另一种寻址模式。为了在汇编语言中表示这种模式,我们在括号后面加上逗号和正或负立即数。在执行指令时,处理器仍然会访问在寄存器中找到的内存地址,但在访问内存后,地址寄存器会根据立即数增加或减少。
我们的 strcpy 实现是一个有用的示例,其中立即后索引寻址很有用:在我们存储到 R0 之后,我们希望 R0 在下一次迭代中增加 1;同样,在我们从 R1 加载之后,我们希望 R1 增加 1。我们可以使用立即后索引寻址来避免我们早期版本的两个 ADD 指令。
目前ARM处理器支持9种寻址方式,分别是立即数寻址、寄存器寻址、寄存器偏移寻址、寄存器间接寻址、基址变址寻址、多寄存器寻址、相对寻址、堆栈寻址和块拷贝寻址。
寻址方式介绍:https://www.cnblogs.com/laojie4321/archive/2012/04/05/2432957.html
对于那些涉及移位的寻址模式,移位技术与算术指令(LSL、LSR、ASR、ROR、RRX)一样。但移位距离不能根据寄存器:距离必须是立即数。
3.3. 初始化内存
我们经常希望保留内存来保存程序中的数据。为此,我们使用指令:指令汇编器做一些事情,而不是简单地将汇编语言指令翻译成相应的机器代码。一种有用的指令是 DCD,它将一个或多个 32 位数值插入机器代码输出。(DCD 神秘地代表定义常量双字。)
在这个例子中,我们创建了标签 primes,它将对应于 2 放入内存的地址。在接下来的四个字节中放置整数 3,然后是 5,依此类推。
在我们的程序中,我们希望将数组的地址加载到寄存器中;为此,我们将素数添加到程序计数器 PC(与 R15 同义)中。下面的片段将第五个素数 (11) 加载到 R1 中。
另一个值得一提的指令是 DCB,用于将字节加载到内存中。因此,我们可以编写以下内容。
但是,我们只为每个数字使用一个字节,因此我们只能包含介于 -128 和 127 之间的数字。我们还可以在列表中包含一个字符串;字符串的每个字符将占用一个字节的内存。
请注意我们如何在字符串之后包含 0。没有这个,字符串不会被 NUL 字符终止。
这里还有一个值得注意的指令是百分号 %。当您希望保留一块内存但您不关心内存的初始值时,这很有用。
3.4多寄存器内存指令
ARM ISA 还包括允许在同一指令中加载或存储多个值的指令。LDMIA 指令就是这样一条指令:它允许从另一个寄存器中指定的地址开始加载到多个寄存器中。在下面的使用示例中,我们将代码用于添加数组的整数,并使用 LDMIA 对其进行修改,以便在循环的每次迭代中处理四个整数。这种策略允许程序使用更少的指令运行,但代价是更高的复杂性。
在执行上面的 LDMIA 指令时,ARM 处理器在 R0 寄存器中查找地址。它将从该地址开始的四个字节加载到 R5,接下来的四个字节加载到 R6,接下来的四个字节加载到 R7,接下来的四个字节加载到 R8。同时,R0 前移 16 个字节,因此在下一次迭代中,LDMIA 指令会将接下来的四个字加载到寄存器中。
大括号内可以是任何寄存器列表,使用破折号表示寄存器范围,并使用逗号分隔范围。因此,指令 LDMIA R0!, { R1-R4, R8, R11-R12 } 将从内存中加载 7 个字。寄存器列出的顺序并不重要;即使我们写 LDMIA R0!, { R11-R12, R8, R1-R4 },R1 也会收到从内存中加载的第一个字。
在我们的例子中,R0 后面的感叹号可以省略;如果省略,则地址寄存器不会被指令更改。也就是说,R0 将继续指向数组中的第一个整数。在上面的示例中,我们希望 R0 发生变化,使其指向下一个由四个整数组成的块以进行下一次迭代,因此我们包含了感叹号。
另一条指令是 STMIA,它将几个寄存器存储到内存中。在下面的示例中,我们将数组中的每个数字移动到下一个位置;因此,数组<2,3,5,7>变为<0,2,3,5>。
请注意 LDMIA 指令如何省略感叹号,以便不修改 R0。这样 STMIA 将存储到刚刚加载到寄存器中的相同地址范围内。STMIA 指令带有感叹号,因为必须修改 R0 以准备循环的下一次迭代。
ARM 处理器包括多重加载和多重存储指令的四种变体;LDM 和 STM 缩写必须始终表示这四种变体之一。
LDMIA, STMIA 之后递增:我们从命名地址开始加载,并不断增加地址。
LDMIB, STMIB 之前的增量:我们开始从比命名地址多四个的地址开始加载,并不断增加地址。LDMDA, STMDA 减量后:我们从命名地址开始加载并进入递减地址。
LDMDB、STMDB 递减前:我们从比指定地址少四个开始加载到递减地址。
在所有四种模式中,编号最高的寄存器始终对应于内存中的最高地址。因此,指令 LDMDA R0, { R1-R4 } 会将 R4 放入由 R0 命名的地址,将 R3 放入 R0 - 4,依此类推。
正如我们将在研究子程序时看到的那样,当我们想将一块未使用的内存用作堆栈时,不同的变体特别有用。
部分参考文献
https://www.cnblogs.com/laojie4321/archive/2012/04/05/2432957.html
http://aratxa.ii.uam.es/~gdrivera/sed/docs/ARMBook.pdf
https://students.mimu.edu.pl/~zbyszek/asm/arm/asm_guide.pdf