第4章 汇编语言简介
汇编语言是一种低级编程语言。通常情况下,汇编语言指令(助记符)和处理器执行的实际二进制操作码之间存在一对一的对应关系。
许多从事应用级开发的程序员很少需要编写汇编语言代码。然而,在某些情况下,掌握汇编代码的知识可能会非常有用,比如需要高度优化代码时、编写编译器时,或者需要低级别地使用C语言中无法直接访问的功能时。汇编语言可能在编写引导代码、设备驱动程序或进行操作系统开发时被需要。最后,在调试C代码时,能够阅读汇编代码也会非常有用,尤其是在理解汇编指令与C语句之间的映射关系时。
希望深入了解ARM汇编语言的程序员还可以参考《ARM编译器工具链汇编器参考》或《ARM架构参考手册》。
4.1 与其他汇编语言的比较
ARM 处理器通常是精简指令集计算机(RISC)处理器。复杂指令集计算机(CISC)处理器,如 x86,有一个丰富的指令集,能够通过单个指令完成复杂的操作。这类处理器通常具有大量的内部逻辑,可以将机器指令解码为一系列内部操作(微代码)。相比之下,RISC 架构拥有较少的、更通用的指令,这些指令可以用显著更少的晶体管来执行,使芯片更便宜且更节能。与其他 RISC 架构类似,ARM 处理器拥有大量的通用寄存器,并且许多指令在一个周期内执行。它具有简单的寻址模式,所有的加载/存储地址都可以通过寄存器内容和指令字段来确定。
ARMv7 架构具有基本的数据处理指令,这些指令允许执行算术运算(如 ADD)和逻辑位操作(如 AND)。它们还具有分支指令,可以将程序执行从程序的一部分转移到另一部分,以支持循环和条件语句。该架构还具有读取和写入外部内存的指令。
ARM 指令集通常被认为是简单、逻辑且高效的。它具有其他处理器没有的特性,同时也缺乏一些其他处理器具备的操作。例如,它不能直接对内存进行数据处理操作。要增加内存位置中的一个值,必须将该值加载到 ARM 寄存器中,递增寄存器,然后使用第三条指令将更新后的值写回内存。指令集架构(ISA)包括将移位与算术或逻辑操作相结合的指令,支持程序循环优化的自动增量和自动减量寻址模式,多重加载和存储指令,以实现高效的堆栈和堆操作,以及块复制能力和几乎所有指令的条件执行功能。与 x86 处理器类似(但与 68K 处理器不同),ARM 指令通常具有两或三操作数格式,其中第一个操作数在大多数情况下指定结果的目标。多重加载和存储指令是此规则的例外。相比之下,68K 将目标放在最后一个操作数中。对于 ARM 指令,通常没有关于哪些寄存器可以用作操作数的限制。下面两个示例展示了不同汇编语言之间的差异。
4.2 ARM 指令集
ARMv7 架构是一种32位的处理器架构。它是一种加载/存储架构,这意味着数据处理指令操作的对象是通用寄存器中的值。只有加载和存储指令才能访问内存。通用寄存器也是32位的。在本书中,当我们提到一个字(word)时,我们指的是32位。因此,双字(doubleword)是64位,半字(halfword)是16位宽。
尽管ARMv7架构是32位架构,但具体的处理器实现并不一定在所有块和互连上都具备32位宽度。例如,在指令获取或数据访问方面,可能存在64位或更宽的路径。
大多数ARM处理器支持多种不同的指令集,而一些处理器(例如Cortex-M3处理器)实际上并不执行最初的ARM指令集。ARM处理器至少可以使用两种指令集:
ARM(32位指令)
这是最初的ARM指令集。Thumb
Thumb指令集最早在ARM7TDMI处理器中引入,仅包含16位指令,能生成更小的程序(内存占用在较小的嵌入式系统中可能是个重要问题),但性能有所牺牲。最近的处理器,包括Cortex-R系列,支持Thumb-2技术,它扩展了Thumb指令集,提供了16位和32位指令的混合。这提供了两全其美的方案:既有类似ARM的性能,又有类似Thumb的代码大小。由于其大小和性能优势,越来越多的代码被编译或汇编为Thumb-2技术。
较旧的ARM处理器中通常包含为ARM状态编译的代码和为Thumb状态编译的代码。ARM代码使用32位指令,功能更强大,执行特定任务所需的指令更少,因此可能被优先用于性能关键的系统部分。它也用于异常处理程序代码,因为在ARM7或ARM9系列处理器上,异常无法在Thumb状态下处理。
相比ARM代码,Thumb代码使用16位指令,执行相同任务时需要更多指令。Thumb代码通常能在指令中编码较小的常量值,并且具有较短的相对跳转。关于跳转的详细信息,请参见第5章的“Branches”。ARM指令的相对跳转范围约为±32MB,而Thumb-2扩展的范围为±16MB。使用仅16位指令时,Thumb也受到限制,条件跳转的范围为±256字节,无条件相对跳转的范围为±2048字节。
然而,由于Thumb指令只有一半大小,程序通常比对应的ARM代码小三分之一。因此,当代码密度非常重要,或者为了减少系统内存需求时,使用Thumb指令。对于直接连接到窄(16位)内存且没有缓存优势的处理器,Thumb代码还可能比ARM代码表现更好。每个周期可以获取一条Thumb指令,而每个32位ARM指令需要两个时钟周期来获取。
在执行Thumb指令时,PC读取的地址为当前指令地址加4。能够直接修改PC的唯一16位Thumb指令是某些MOV和ADD指令的编码。写入PC的值会通过忽略其最低有效位,将其视为0来强制为半字对齐。
在ARMCC中,可以使用 --thumb
或 --arm
(默认值)选项来选择用于编译的指令集。程序可以在运行时在这两种指令集之间进行分支切换。
当前使用的指令集由 CPSR 的 T 位指示,核心处于 ARM 状态(T = 0)或 Thumb 状态(T = 1)。代码必须明确地编译或汇编为其中一种状态。使用显式指令可在指令集之间切换。调用为不同状态编译的函数被称为互操作。我们将在后面的 Interworking 部分中更详细地探讨这一点。
对于 Thumb 汇编代码,通常可以选择 16 位和 32 位指令编码,默认情况下生成 16 位版本。可以使用 .W(32 位)和 .N(16 位)宽度说明符来强制使用特定编码(如果存在这样的编码),例如:
4.2.1 Thumb-2
尽管一直有相反的传言,但实际上并不存在所谓的“Thumb-2指令集”。Thumb-2技术在ARMv6T2中引入,并且在ARMv7中是必需的。该技术扩展了原始的16位Thumb指令集,以包含32位指令。ARMv6T2中包含的32位Thumb指令范围使得Thumb代码能够实现与ARM代码相似的性能,同时其代码密度比纯16位Thumb代码更高。
4.3 GNU Assembler 简介
GNU Assembler 是 GNU 工具的一部分,用于将汇编语言源代码转换为二进制目标文件。该汇编器在 GNU Assembler Manual 中有详细的文档说明,您可以在线查阅这些文档。如果您的系统上已经安装了 GNU 工具,也可以在 gnutools/doc
子目录中找到文档。GNU Assembler 的文档也可以在 Ubuntu 的 /gcc-doc/
包中获取。
接下来是一个简短的描述,旨在突出 GNU Assembler 与标准 ARM 汇编语言之间的语法差异,并提供足够的信息让您能够开始使用这些工具。
GNU 工具组件的名称具有前缀,以指示选择的目标选项,包括操作系统。例如,arm-none-eabi-gcc
可能用于基于 ARM EABI 的裸机系统。
4.3.1 调用 GNU Assembler
您可以通过运行 arm-none-eabi-as
程序来汇编 ARM 汇编语言源文件的内容。
arm-none-eabi-as -g -o filename.o filename.s
选项 -g
请求汇编器在输出文件中包含调试信息。
当您的所有源文件都已汇编成二进制目标文件(扩展名为 .o
)后,您可以使用 GNU 链接器创建最终的 ELF 格式可执行文件。
通过执行以下命令来完成:
arm-none-eabi-ld -o filename.elf filename.o
对于更复杂的程序,当有许多单独的源文件时,通常会使用类似 make
的工具来控制构建过程。
你可以使用 arm-none-eabi-gdb
或 arm-none-eabi-insight
提供的调试器在主机上运行可执行文件,作为使用真实目标处理器的替代方法。
4.3.2 GNU 汇编器语法
GNU 汇编器(GNU Assembler)可以针对多种不同的处理器架构,因此它并不是ARM特有的。这意味着它的语法与其他ARM汇编器(如ARM工具链)有所不同。GNU汇编器对其支持的所有处理器架构使用相同的语法。
汇编语言源文件由一系列语句组成,每行一条语句。
每条语句由以下三个可选部分组成,顺序如下:
label: instruction @ comment
标签(label):用于标识该指令的地址。标签可以用作分支指令的目标,或者用于加载和存储指令。标签可以是一个字母,后面可跟一系列字母数字字符,最后以冒号结尾。
指令(instruction):可以是ARM汇编指令,也可以是汇编器指令(伪指令)。伪指令是用于指示汇编器自身执行某些操作的指令。这些指令用于控制代码段和对齐,或创建数据等。
注释(comment):在
@
符号之后的所有内容都会被视为注释并被忽略(除非它在字符串内部)。也可以使用C风格的注释符号“/*
”和“*/
”。
在链接时,如果在源代码中没有明确提供入口点,可以在命令行上指定入口点。
4.3.3 段
一个包含代码的可执行程序至少会有一个段,按惯例称为 .text
。数据可以包含在 .data
段中。
这些名称的指令使你能够指定源文件中的内容应放入哪一个段。可执行代码应出现在 .text
段中,读取或写入的数据应在 .data
段中。只读常量可以出现在 .rodata
段中。零初始化的数据出现在 .bss
段中。Block Started by Symbol(bss)段定义了未初始化静态数据的空间。
4.3.4 汇编器指令
这是GNU工具与其他汇编器之间的一个主要区别领域。所有汇编器指令都以句点“.
”开始。GNU文档中描述了这些指令的完整列表。这里列出一些常用的指令子集:
.align
使汇编器在数据段中用零值字节填充,或在代码中用NOP指令填充,确保下一个位置对齐到字边界。.align n
在ARM处理器上提供2^n
的对齐。.ascii “string”
将字符串字面量按指定方式插入目标文件中,不加NUL字符终止。可以使用逗号作为分隔符指定多个字符串。.asciiz
功能与.ascii
相同,但后面会附加一个NUL字符(值为0的字节)。.byte expression, .hword expression, .word expression
将字节、半字或字值插入目标文件中。可以使用逗号作为分隔符指定多个值。也可以使用.2byte
和.4byte
作为同义词。.data
使后续语句放置在最终可执行文件的数据段中。.end
标记此源代码文件的结束。汇编器在此之后不会处理文件中的任何内容。.equ symbol, expression
将符号的值设置为表达式。=
符号和.set
有相同的效果。.extern symbol
指示符号在另一个源代码文件中定义。.global symbol
告诉汇编器符号应对其他源文件和链接器全局可见。.include “filename”
将filename
的内容插入到当前源文件中,通常用于包含包含共享定义的头文件。.text
将后续语句的目标切换到最终输出目标文件的文本段。汇编指令必须始终位于文本段中。
供参考,下表展示了常见的汇编器指令,并对比了GNU和ARM工具的用法。并非所有指令都列出,某些情况下它们之间的对应关系可能不是100%的。
4.3.5 表达式
汇编指令和汇编器指令通常需要一个整数操作数。在汇编器中,这表示为需要计算的表达式。通常,这个表达式是一个以十进制、十六进制(以 0x
前缀)或二进制(以 0b
前缀)表示的整数,或者是用单引号括起来的ASCII字符。
此外,汇编器还可以计算标准的数学和逻辑表达式以生成常量值。这些表达式可以使用标签和其他预定义值。表达式可以生成绝对值或相对值。绝对值是位置无关的且常量的。相对值是相对于某些链接器定义的地址指定的,这些地址在生成可执行映像时确定,例如分支的目标地址。
4.3.6 GNU 工具命名约定
在GCC中,寄存器的命名如下:
通用寄存器: R0 - R15。
堆栈指针寄存器: SP (R13)。
帧指针寄存器: FP (R11)。
链接寄存器: LR (R14)。
程序计数器: PC (R15)。
程序状态寄存器标志: xPSR, xPSR_all, xPSR_f, xPSR_x, xPSR_ctl, xPSR_fs, xPSR_fx, xPSR_f, xPSR_cs, xPSR_cf, xPSR_cx(其中 x = C 表示当前的,S 表示保存的)。参见程序状态寄存器,第三章。
4.4 ARM 工具汇编语言
ARM工具现在使用的统一汇编语言(UAL)格式允许对ARM和Thumb指令集使用相同的规范语法。ARM工具的汇编语法与GNU汇编器的语法不完全相同,特别是在预处理和伪指令方面,这些伪指令不会直接映射到操作码。在下一章中,我们将更详细地查看各个汇编语言指令。在此之前,我们将先了解用于指定指令和寄存器的基本语法。本书中的汇编语言示例同时使用了UAL和GNU汇编语法。
UAL使得编写的汇编代码可以被汇编以在所有ARM处理器上运行。在过去,必须为ARM或Thumb状态明确编写代码。使用UAL时,可以在汇编时而不是在编写代码时为不同的指令集汇编相同的代码。这可以通过使用命令行开关或内联指令来实现。遗留代码仍然可以正确汇编。值得注意的是,GNU汇编器现在通过使用 .syntax
指令支持UAL,尽管其语法可能与ARM工具汇编器的语法不完全相同。
4.4.1 ARM 汇编器语法
ARM 汇编器源文件由一系列语句组成,每行一个语句。
每个语句有三个可选部分,按以下顺序排列:label instruction; comment
标签 (label):标签允许您标识该指令的地址。之后可以将其用作跳转指令或加载和存储指令的目标。
指令 (instruction):指令可以是汇编指令,也可以是汇编器指令。这些是伪指令,告诉汇编器执行某些操作。这些指令在控制段和对齐,或创建数据时是必需的。
注释 (comment):
;
符号后的所有内容被视为注释并被忽略(除非它在字符串内部)。也可以使用 C 风格的注释定界符/*
和*/
。
4.4.2 标签
标签必须从行的第一个字符开始。如果行没有标签,则需要使用空格或制表符作为行的开始。如果有标签,汇编器将标签设置为对象文件中相应指令的地址。标签可以用作跳转、加载和存储指令的目标。
在上面示例中,Loop
是一个标签,条件分支指令 (BNE Loop
) 被汇编成一种方式,使得分支指令中编码的偏移量指向与标签 Loop
相关联的 MUL
指令的地址。
4.4.3 汇编器指令
大多数行通常包含一个汇编语言指令,这些指令会被工具转换为其二进制等效形式,或者是一个指令,告诉汇编器执行某些操作。它也可以是一个伪指令,即由汇编器转换为一个或多个实际指令的指令。我们将在第5章中查看硬件中实际可用的指令,这里主要关注汇编器指令。汇编器指令执行广泛的任务,可以用于将代码或数据放置在内存中的特定地址,创建对其他程序的引用等等。
例如,Define Constant (DCD, DCB, DCW)
指令允许我们将数据放置到代码中。这些数据可以用数字(十进制、十六进制、二进制)或 ASCII 字符表示。数据项可以是单个项或用逗号分隔的列表。DCB
用于字节大小的数据,DCD
可用于字大小的数据,DCW
用于半字大小的数据项。
例如,你可能有:
MESSAGE DCB "Hello World!",0
这将生成一系列字节,对应于字符串中的 ASCII 字符,以 0 结尾。MESSAGE
是一个标签,你可以用它来获取这些数据的地址。类似地,你可能有以十六进制表示的数据项:
Masks DCD 0x100, 0x80, 0x40, 0x20, 0x10
EQU
指令允许我们为地址或数据值分配名称。例如:
CtrlD EQU 4
TUBE EQU 0x30000000
然后,你可以在其他指令中使用这些标签,作为待评估的表达式的一部分。EQU
实际上不会在程序可执行文件中放置任何东西——它只是将名称设置为一个值,以便在其他指令中使用,在汇编器的符号表中。使用这些名称可以使代码更易于阅读,但也可以使得如果你更改代码中某个地址或值时,你只需要修改原始定义,而不必逐一更改所有引用。通常会将 EQU
定义放在一起,通常在程序或函数的开头,或在单独的包含文件中。
AREA
伪指令用于告知汇编器如何将代码或数据分组到逻辑部分,以便后续由链接器进行放置。例如,异常向量可能必须放置在固定地址。汇编器跟踪每个指令或数据项在内存中的位置。AREA
指令可以用于修改该位置。
ALIGN
指令用于将当前位置对齐到指定的边界。它通常通过填充(必要时)零或 NOP 指令来实现对齐,尽管也可以使用该指令指定填充值。默认行为是将当前位置设置为下一个字(四字节)边界,但也可以指定更大的边界大小和从该边界的偏移量。这可能是为了满足某些指令的对齐要求(例如 LDRD 和 STRD 双字内存传输),或与缓存边界对齐。与 GNU 汇编器上的 .align
指令类似,ALIGN n
指令在 ARM 处理器上提供 2^n 对齐。
END
用于表示汇编语言源程序的结束。未使用 END
指令将导致返回错误。INCLUDE
告诉汇编器将另一个文件的内容包含到当前文件中。包含文件可以作为在相关文件之间共享定义的一种简单机制。
4.5 互操作性
当处理器执行ARM指令时,称其为ARM状态;当处理器执行Thumb指令时,称其为Thumb状态。处于特定状态的处理器只能合理地执行来自该指令集的指令。必须确保处理器不会接收到错误指令集的指令。
每个指令集都包括更改处理器状态的指令。ARM和Thumb代码可以混合使用,只要代码符合ARM和Thumb过程调用标准的要求。编译器生成的代码将始终遵循这些要求,但汇编语言程序员必须注意遵守指定的规则。
处理器状态的选择由CPSR中的T位控制。参见第三章的图。当T位为1时,处理器处于Thumb状态;当T位为0时,处理器处于ARM状态。然而,当T位被修改时,还需要刷新指令流水线(以避免在一个状态下解码指令而在另一个状态下执行指令的问题)。使用特定指令可以实现这一点。这些指令是BX(带交换的分支)和BLX(带交换的分支和链接)。LDR的PC和POP/LDM的PC也具有这种行为。除了使用这些指令更改处理器状态外,汇编程序员还必须使用适当的指令来告诉汇编器生成适当状态的代码。
BX或BLX指令分支到指定寄存器中的地址或操作码中指定的偏移量。分支目标地址的位[0]值决定了执行是否继续在ARM状态还是Thumb状态。这仅适用于采用寄存器的BX/BLX形式。PC相对形式不能生成低位位(lsb)为非零的地址,因此总是会更改状态。ARM(对齐到字边界)和Thumb(对齐到半字边界)指令不会使用位[0]来形成地址。因此,该位可以安全地用于提供额外的信息,以确定BX或BLX指令是否应将状态更改为ARM(地址位[0]=0)或Thumb(地址位[0]=1)。如果调用者的指令集与标签的指令集不同,则可以在链接时将BL标签转换为BLX标签,假设它是无条件的。
这些指令的典型用法是在一个函数使用BL或BLX指令调用另一个函数时,以及在从该函数返回时使用BX LR指令。或者,可以有一个非叶子函数,在入口时将链接寄存器推送到堆栈上,退出时将存储的链接寄存器从堆栈弹出到程序计数器。在这里,代替使用BX LR指令返回,使用的是内存加载。修改PC的内存加载指令也可能会更改处理器状态,具体取决于加载地址的位[0]的值。
4.6 识别汇编代码
面对一段汇编语言源代码时,能够确定使用了哪种指令集以及它所针对的汇编器类型是非常有用的。
较旧的ARM汇编语言代码可能包含三(甚至四)操作数的指令(例如,ADD R0, R1, R2
)或条件执行的非分支指令(例如,ADDNE R0, R0, #1
)。文件名扩展名通常为 .s
或 .S
。
针对较新的UAL的代码将包含指令 .syntax unified
,但其余部分看起来类似于传统的ARM汇编语言。立即操作数前的井号(或哈希符号)#
可以省略。条件指令序列必须由紧接着的 IT
指令引导,该指令在第5章中描述。此类代码将汇编成固定大小的32位(ARM)指令,或根据 .thumb
或 .arm
指令的存在,混合大小的(16位和32位)Thumb指令。
有时你可能会遇到用16位Thumb汇编语言编写的代码。这类代码可能包含 .code 16
、.thumb
或 .thumb_func
等指令,但不会指定 .syntax unified
。它使用两个操作数的指令,尽管 ADD
和 SUB
有时可以有三个操作数。只有分支指令可以有条件地执行。
所有GCC内联汇编,例如 .c
、.h
、.cpp
、.cxx
和 .c++
代码,可以根据GCC配置和命令行开关(-marm
或 -mthumb
)构建为Thumb或ARM代码。